diff --git a/src/index.ts b/src/index.ts
index 96b8525dd..10de28bf2 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,9 +1,9 @@
///
import 'tslib'
-import { forEach, clone, uuid, concat, dropEle, hasMorePages, pagination, eventToRE } from './utils'
+import { forEach, clone, uuid, concat, dropEle, hasMorePages, pagination, eventToRE, normBulkUpdate } from './utils'
-export { hasMorePages, pagination }
+export { hasMorePages, pagination, normBulkUpdate }
export const Utils = { forEach, clone, uuid, concat, dropEle }
export { eventParser } from './sockets/EventParser'
diff --git a/src/utils/httpclient.ts b/src/utils/httpclient.ts
new file mode 100644
index 000000000..944067244
--- /dev/null
+++ b/src/utils/httpclient.ts
@@ -0,0 +1,66 @@
+import { Omit } from './internalTypes'
+import { SDKLogger } from './Logger'
+
+/**
+ * 从类型 T(如 `{ taskIds: TaskId[] }`)上获得给定字段 K
+ * (如 `taskIds`)对应的数组的元素类型(如 `TaskId`)。如果
+ * `T[K]` 不是数组类型,则放弃类型推断,返回 any。
+ */
+export type ArrayPropertyElement<
+ T,
+ K extends keyof T
+> = T[K] extends (infer U)[] ? U : any
+
+/**
+ * 生成的结果类型,替换了类型 T(如 `{ taskIds: TaskId[], isArchived: boolean }`)
+ * 上的字段 K(如 `taskIds`)为字段 S(如 `_id`),而字段 S
+ * 对应的值类型则是字段 K 对应的数组值的元素类型(如 `TaskId`)。
+ * 如果 `T[K]` 不是数组类型,则字段 S(如 `_id`)的类型将是 any。
+ */
+export type NormBulkUpdateResult<
+ T,
+ K extends keyof T,
+ U extends string
+> = Array> & Omit>
+
+/**
+ * 将批量 PUT 的返回结果转变为可以直接被缓存层消费的数据。
+ * 用法如:有 response$ 的元素形状为
+ * `{ taskIds: TaskId[], isArchived: boolean, updated: string }`
+ * 则 `response$.map(normBulkUpdate('taskIds', '_id'))` 将推出元素形状为
+ * `{ _id: TaskId, isArchived: boolean, updated: string }[]`
+ * 的数据。
+ */
+export const normBulkUpdate = <
+ T extends { [key: string]: any },
+ K extends keyof T = keyof T,
+ U extends string = string
+>(
+ responseIdsField: K,
+ entityIdField: U
+) => (
+ response: T
+): NormBulkUpdateResult => {
+ if (response == null || typeof response !== 'object') {
+ return []
+ }
+ const { [responseIdsField]: ids, ...rest } = response as any
+ return !ids
+ ? []
+ : ids
+ .map((id: ArrayPropertyElement) => {
+ const currentValue = rest[entityIdField]
+
+ if (currentValue == null || currentValue === id /* not likely */) {
+ return { ...rest, [entityIdField]: id }
+ }
+
+ const incoming = `${entityIdField}-${id}`
+ const current = `${entityIdField}-${currentValue}`
+ SDKLogger.warn('normBulkUpdate:' +
+ ` specified key-value pair(${incoming})` +
+ ` conflicts with an existing one(${current}).)`
+ )
+ return rest
+ })
+}
diff --git a/src/utils/index.ts b/src/utils/index.ts
index dfe59e77e..44003f7dc 100644
--- a/src/utils/index.ts
+++ b/src/utils/index.ts
@@ -2,3 +2,4 @@ export * from './helper'
export * from './internalTypes'
export { createProxy } from './proxy'
export { eventToRegexp as eventToRE } from './eventToRegexp'
+export * from './httpclient'
diff --git a/src/utils/internalTypes.ts b/src/utils/internalTypes.ts
index a5da2075c..27d4894bd 100644
--- a/src/utils/internalTypes.ts
+++ b/src/utils/internalTypes.ts
@@ -18,6 +18,8 @@ export type Dict = {
[key: string]: T
}
+export type Omit = Pick>
+
export type TableInfo = {
tabName: string,
pkName: string
diff --git a/test/app.ts b/test/app.ts
index 0cf16af35..6fe98e8ee 100644
--- a/test/app.ts
+++ b/test/app.ts
@@ -13,3 +13,4 @@ import './mock'
import './apis'
import './sockets'
import './net'
+import './utils/index'
diff --git a/test/utils/httpclient.ts b/test/utils/httpclient.ts
new file mode 100644
index 000000000..5a6726074
--- /dev/null
+++ b/test/utils/httpclient.ts
@@ -0,0 +1,79 @@
+import { expect } from 'chai'
+import { it, describe } from 'tman'
+import { TaskId } from 'teambition-types'
+import { normBulkUpdate } from '../../src/utils'
+
+describe('httpclient utils spec', () => {
+
+ type ResponsePayload = {
+ taskIds: TaskId[]
+ isArchived: boolean
+ updated: string
+ }
+
+ it('normBoldUpdate should produce [] for undefined response', () => {
+ const norm = normBulkUpdate('taskIds', '_id')
+ expect(norm(undefined)).to.deep.equal([])
+ expect(norm(null)).to.deep.equal([])
+ expect(norm('success')).to.deep.equal([])
+ })
+
+ it('normBulkUpdate should produce [] when responseIdsField is undefined', () => {
+ const norm = normBulkUpdate('_taskIds', '_id')
+ expect(norm({
+ taskIds: ['123', '456'],
+ isArchived: true,
+ updated: '2018-08-21T05:43:10.000Z'
+ })).to.deep.equal([])
+ })
+
+ it('normBulkUpdate should produce [] when responseIdsField is empty', () => {
+ const norm = normBulkUpdate('taskIds', '_id')
+ expect(norm({
+ taskIds: [],
+ isArchived: true,
+ updated: '2018-08-21T05:43:10.000Z'
+ })).to.deep.equal([])
+ })
+
+ it('normBulkUpdate should work when responseIdsField and entityIdField are the same', () => {
+ const norm = normBulkUpdate('_id', '_id')
+ expect(norm({
+ _id: ['123' as TaskId, '456' as TaskId],
+ isArchived: true,
+ updated: '2018-08-21T05:43:10.000Z'
+ })).to.deep.equal([
+ { _id: '123', isArchived: true, updated: '2018-08-21T05:43:10.000Z' },
+ { _id: '456', isArchived: true, updated: '2018-08-21T05:43:10.000Z' }
+ ])
+ })
+
+ it('normBulkUpdate should work when responseIdsField and entityIdField are different', () => {
+ const norm = normBulkUpdate('taskIds', '_id')
+ expect(norm({
+ taskIds: ['123' as TaskId, '456' as TaskId],
+ isArchived: true,
+ updated: '2018-08-21T05:43:10.000Z'
+ })).to.deep.equal([
+ { _id: '123', isArchived: true, updated: '2018-08-21T05:43:10.000Z' },
+ { _id: '456', isArchived: true, updated: '2018-08-21T05:43:10.000Z' }
+ ])
+ })
+
+ it('normBulkUpdate should not overwrite when property conflict happends', () => {
+ const norm = normBulkUpdate(
+ 'taskIds',
+ 'cid' // 语义为 backbone 的 client id
+ )
+ expect(norm({
+ taskIds: ['123', '456'],
+ cid: '789', // 语义为 child id
+ isArchived: true,
+ updated: '2018-08-21T05:43:10.000Z'
+ })).to.deep.equal([
+ { cid: '789', isArchived: true, updated: '2018-08-21T05:43:10.000Z' },
+ { cid: '789', isArchived: true, updated: '2018-08-21T05:43:10.000Z' }
+ ])
+ })
+
+})
diff --git a/test/utils/index.ts b/test/utils/index.ts
new file mode 100644
index 000000000..00442d7b4
--- /dev/null
+++ b/test/utils/index.ts
@@ -0,0 +1 @@
+import './httpclient'