@mymehq/sdk 3.2.0 → 3.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1,11 +1,14 @@
1
- import { ConflictSnapshot, ItemState, CreateItemInput, Item, PaginatedResult, ItemWithMetadata, Version, Edge, Metadata, SearchResult, CreateEdgeInput, EdgeTypeSchema, TypeSchema, CreateKeyInput, ApiKey, UpdateKeyInput, CreateWebhookInput, Webhook, UpdateWebhookInput, WebhookDelivery, TenantConfig } from '@mymehq/shared';
2
- export { ApiKey, ConflictSnapshot, CreateItemInput, CreateKeyInput, Item, ItemState, Metadata, PaginatedResult, SearchResult, TypeSchema, UpdateKeyInput, Version } from '@mymehq/shared';
1
+ import { MergeStrategy, ConflictSnapshot, MergePolicy, Item, ItemState, CreateItemInput, PaginatedResult, ItemWithMetadata, Version, Edge, Metadata, SearchResult, CreateEdgeInput, EdgeTypeSchema, TypeSchema, CreateKeyInput, ApiKey, UpdateKeyInput, CreateWebhookInput, Webhook, UpdateWebhookInput, WebhookDelivery, TenantConfig } from '@mymehq/shared';
2
+ export { ApiKey, ConflictSnapshot, CreateItemInput, CreateKeyInput, Item, ItemState, MergePolicy, MergeStrategy, Metadata, PaginatedResult, SearchResult, TypeSchema, UpdateKeyInput, Version } from '@mymehq/shared';
3
3
 
4
4
  /**
5
5
  * Conflict resolution strategy for item updates.
6
6
  *
7
- * - `"auto"` (default): Non-conflicting field changes merge automatically.
8
- * Conflicting fields use the server's current value. Retries up to 3 times.
7
+ * - `"auto"` (default): policy-aware. Per-field strategies declared on the
8
+ * type drive the merge. `last_writer_wins` fields take the server's value;
9
+ * `keep_both_copies` fields spawn a sibling item tagged `conflicted-copy`
10
+ * with the client's value, leaving the original at the server's value.
11
+ * The default for fields not listed in the policy is `last_writer_wins`.
9
12
  * - `"manual"`: Throws a `ConflictError` with both versions and the list of
10
13
  * conflicting fields. The caller decides how to resolve.
11
14
  * - `"callback"`: Calls a custom `ConflictResolver` function with the conflict
@@ -17,8 +20,32 @@ interface ConflictData {
17
20
  ancestor: ConflictSnapshot;
18
21
  conflictingFields: string[];
19
22
  clientPatch: Record<string, unknown>;
23
+ /** Resolved per-field merge policy for the conflicting item's type. */
24
+ mergePolicy: MergePolicy;
20
25
  }
21
26
  type ConflictResolver = (conflict: ConflictData) => Record<string, unknown> | Promise<Record<string, unknown>>;
27
+ /**
28
+ * Notification payload emitted by the auto-merge path. Surfaces the per-field
29
+ * outcome so callers can present an accurate UI affordance (e.g. "your edit
30
+ * was saved as a conflicted copy").
31
+ *
32
+ * - `itemId` — the original item that was being updated.
33
+ * - `mergedItemId` — the same id; included so listeners that care about
34
+ * identity in event streams have a stable field.
35
+ * - `conflictedCopyId` — present when at least one `keep_both_copies` field
36
+ * conflicted; the id of the spawned sibling that carries the client's edit.
37
+ * - `fields` — the list of fields that conflicted.
38
+ * - `strategy` — keyed by field name, the strategy applied to each.
39
+ */
40
+ interface ConflictAutoMergedEvent {
41
+ itemId: string;
42
+ mergedItemId: string;
43
+ conflictedCopyId?: string;
44
+ fields: string[];
45
+ strategy: Record<string, MergeStrategy>;
46
+ }
47
+ /** Optional callback fired when the auto-merge path completes successfully. */
48
+ type ConflictAutoMergeListener = (event: ConflictAutoMergedEvent) => void | Promise<void>;
22
49
 
23
50
  interface ClientConfig {
24
51
  url: string;
@@ -31,6 +58,13 @@ interface ClientConfig {
31
58
  * use the server's value.
32
59
  */
33
60
  conflictStrategy?: ConflictStrategy;
61
+ /**
62
+ * Default listener invoked after the auto-merge path completes successfully.
63
+ * Receives a `ConflictAutoMergedEvent` describing the per-field outcome and
64
+ * any spawned conflicted-copy id. Per-call overridable via
65
+ * `UpdateOptions.onAutoMerge`.
66
+ */
67
+ onConflictAutoMerge?: ConflictAutoMergeListener;
34
68
  timeoutMs?: number;
35
69
  cdnBaseUrl?: string;
36
70
  }
@@ -48,6 +82,16 @@ interface UpdateOptions {
48
82
  /** Toggle library / ambient state. Independent of the version-merge path
49
83
  * for `properties`; a library-only update never conflicts. */
50
84
  library?: boolean;
85
+ /**
86
+ * Item type. Required by the `auto` strategy when a `keep_both_copies`
87
+ * conflict spawns a sibling item. Omit to let the SDK pre-fetch it.
88
+ */
89
+ type?: string;
90
+ /**
91
+ * Listener invoked after the auto-merge path completes successfully.
92
+ * Overrides the client's default `onConflictAutoMerge` for this call.
93
+ */
94
+ onAutoMerge?: ConflictAutoMergeListener;
51
95
  }
52
96
  interface ListFilters {
53
97
  type?: string;
@@ -68,7 +112,25 @@ interface ListFilters {
68
112
  until?: string;
69
113
  limit?: number;
70
114
  cursor?: string;
115
+ /**
116
+ * Opt-in hydrations on the list response. Comma-separated values; each
117
+ * value widens the per-item shape. Prefer the typed helpers
118
+ * (`listWithMetadata`, `listWithExtensions`) over raw strings.
119
+ *
120
+ * - `metadata` — wraps each entry as `{ item, metadata }`.
121
+ * - `edges` — hydrates outbound edges per item grouped by type.
122
+ * - `extensions` — hydrates extension namespaces (filtered by caller
123
+ * permissions, same rule as `GET /items/:id/extensions`).
124
+ *
125
+ * Lists are lean by default; opt into extras when the UI needs them
126
+ * to avoid N+1 per-item round trips.
127
+ */
128
+ include?: string;
71
129
  }
130
+ /** Item paired with its hydrated extension namespaces. */
131
+ type ItemWithExtensions = Item & {
132
+ extensions: Record<string, Record<string, unknown>>;
133
+ };
72
134
  interface SearchFilters {
73
135
  type?: string;
74
136
  state?: ItemState;
@@ -86,6 +148,7 @@ interface MetadataInput {
86
148
  declare class MymeClient {
87
149
  private readonly transport;
88
150
  private readonly defaultConflictStrategy;
151
+ private readonly defaultOnConflictAutoMerge?;
89
152
  private readonly apiBaseUrl;
90
153
  private readonly cdnBaseUrl?;
91
154
  constructor(config: ClientConfig);
@@ -98,6 +161,14 @@ declare class MymeClient {
98
161
  get: (id: string) => Promise<Item>;
99
162
  list: (filters?: ListFilters) => Promise<PaginatedResult<Item>>;
100
163
  listWithMetadata: (filters?: Omit<ListFilters, "include">) => Promise<PaginatedResult<ItemWithMetadata>>;
164
+ /**
165
+ * List items with their extension namespaces hydrated inline. Avoids
166
+ * the N+1 pattern of listing then calling `GET /items/:id/extensions`
167
+ * per item. Extensions are filtered by the caller's permissions —
168
+ * admins see every namespace, members see what they can read or
169
+ * write.
170
+ */
171
+ listWithExtensions: (filters?: Omit<ListFilters, "include">) => Promise<PaginatedResult<ItemWithExtensions>>;
101
172
  update: (id: string, properties: Record<string, unknown>, options?: UpdateOptions) => Promise<Item>;
102
173
  delete: (id: string) => Promise<void>;
103
174
  /** Permanently delete a trashed item (admin only). Item must already
@@ -281,4 +352,4 @@ declare class ConflictError extends MymeError {
281
352
  constructor(current: ConflictSnapshot, ancestor: ConflictSnapshot, conflictingFields: string[], clientPatch: Record<string, unknown>);
282
353
  }
283
354
 
284
- export { type ClientConfig, type ConflictData, ConflictError, type ConflictResolver, type ConflictStrategy, ForbiddenError, type ListFilters, type MetadataInput, MymeClient, MymeError, NotFoundError, type SearchFilters, UnauthorizedError, type UpdateOptions, ValidationError };
355
+ export { type ClientConfig, type ConflictAutoMergeListener, type ConflictAutoMergedEvent, type ConflictData, ConflictError, type ConflictResolver, type ConflictStrategy, ForbiddenError, type ItemWithExtensions, type ListFilters, type MetadataInput, MymeClient, MymeError, NotFoundError, type SearchFilters, UnauthorizedError, type UpdateOptions, ValidationError };
package/dist/index.js CHANGED
@@ -156,14 +156,41 @@ var MAX_RETRIES = 3;
156
156
  function isConflictResponse(body) {
157
157
  return typeof body === "object" && body !== null && "error" in body && "current" in body && "conflicting_fields" in body;
158
158
  }
159
- function autoMerge(conflict) {
159
+ function strategyFor(field, policy) {
160
+ return policy?.fields?.[field] ?? policy?.default ?? "last_writer_wins";
161
+ }
162
+ function planAutoMerge(conflict) {
160
163
  const merged = { ...conflict.current.properties };
164
+ const keepBothFields = [];
165
+ const strategyByField = {};
161
166
  for (const [key, value] of Object.entries(conflict.clientPatch)) {
162
- if (!conflict.conflictingFields.includes(key)) {
167
+ const isConflicting = conflict.conflictingFields.includes(key);
168
+ if (!isConflicting) {
163
169
  merged[key] = value;
170
+ continue;
171
+ }
172
+ const strategy = strategyFor(key, conflict.mergePolicy);
173
+ strategyByField[key] = strategy;
174
+ if (strategy === "keep_both_copies") {
175
+ keepBothFields.push(key);
164
176
  }
165
177
  }
166
- return merged;
178
+ return { merged, keepBothFields, strategyByField };
179
+ }
180
+ async function keepBothFlow(transport, type, current, clientPatch, keepBothFields) {
181
+ const properties = { ...current };
182
+ for (const field of keepBothFields) {
183
+ properties[field] = clientPatch[field];
184
+ }
185
+ const input = {
186
+ type,
187
+ properties,
188
+ tags: ["conflicted-copy"]
189
+ };
190
+ const res = await transport.request("POST", "/items", {
191
+ body: input
192
+ });
193
+ return res.item;
167
194
  }
168
195
  function toConflictError(response, clientPatch) {
169
196
  return new ConflictError(
@@ -173,9 +200,10 @@ function toConflictError(response, clientPatch) {
173
200
  clientPatch
174
201
  );
175
202
  }
176
- async function handleConflictUpdate(transport, itemId, clientPatch, version, strategy, resolver, library) {
203
+ async function handleConflictUpdate(transport, itemId, itemType, clientPatch, version, strategy, resolver, library, onAutoMerge) {
177
204
  let properties = clientPatch;
178
205
  let currentVersion = version;
206
+ let pendingAutoMergeEvent;
179
207
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
180
208
  const result = await transport.requestWithConflict("PATCH", `/items/${itemId}`, {
181
209
  body: {
@@ -185,6 +213,9 @@ async function handleConflictUpdate(transport, itemId, clientPatch, version, str
185
213
  }
186
214
  });
187
215
  if (!isConflictResponse(result)) {
216
+ if (pendingAutoMergeEvent && onAutoMerge) {
217
+ await onAutoMerge(pendingAutoMergeEvent);
218
+ }
188
219
  return result.item;
189
220
  }
190
221
  if (strategy === "manual") {
@@ -197,10 +228,30 @@ async function handleConflictUpdate(transport, itemId, clientPatch, version, str
197
228
  current: result.current,
198
229
  ancestor: result.ancestor,
199
230
  conflictingFields: result.conflicting_fields,
200
- clientPatch
231
+ clientPatch,
232
+ mergePolicy: result.merge_policy
201
233
  };
202
234
  if (strategy === "auto") {
203
- properties = autoMerge(conflict);
235
+ const plan = planAutoMerge(conflict);
236
+ let conflictedCopyId;
237
+ if (plan.keepBothFields.length > 0) {
238
+ const sibling = await keepBothFlow(
239
+ transport,
240
+ itemType,
241
+ result.current.properties,
242
+ clientPatch,
243
+ plan.keepBothFields
244
+ );
245
+ conflictedCopyId = sibling.id;
246
+ }
247
+ properties = plan.merged;
248
+ pendingAutoMergeEvent = {
249
+ itemId,
250
+ mergedItemId: itemId,
251
+ conflictedCopyId,
252
+ fields: conflict.conflictingFields,
253
+ strategy: plan.strategyByField
254
+ };
204
255
  } else {
205
256
  if (!resolver) {
206
257
  throw toConflictError(result, clientPatch);
@@ -221,6 +272,7 @@ async function handleConflictUpdate(transport, itemId, clientPatch, version, str
221
272
  var MymeClient = class {
222
273
  transport;
223
274
  defaultConflictStrategy;
275
+ defaultOnConflictAutoMerge;
224
276
  apiBaseUrl;
225
277
  cdnBaseUrl;
226
278
  constructor(config) {
@@ -232,6 +284,7 @@ var MymeClient = class {
232
284
  timeoutMs: config.timeoutMs
233
285
  });
234
286
  this.defaultConflictStrategy = config.conflictStrategy ?? "auto";
287
+ this.defaultOnConflictAutoMerge = config.onConflictAutoMerge;
235
288
  this.cdnBaseUrl = config.cdnBaseUrl;
236
289
  }
237
290
  // ---- Items ----
@@ -268,21 +321,45 @@ var MymeClient = class {
268
321
  }
269
322
  );
270
323
  },
324
+ /**
325
+ * List items with their extension namespaces hydrated inline. Avoids
326
+ * the N+1 pattern of listing then calling `GET /items/:id/extensions`
327
+ * per item. Extensions are filtered by the caller's permissions —
328
+ * admins see every namespace, members see what they can read or
329
+ * write.
330
+ */
331
+ listWithExtensions: async (filters) => {
332
+ return this.transport.request(
333
+ "GET",
334
+ "/items",
335
+ {
336
+ query: {
337
+ ...filters,
338
+ include: "extensions"
339
+ }
340
+ }
341
+ );
342
+ },
271
343
  update: async (id, properties, options) => {
272
344
  let version = options?.version;
273
- if (version === void 0) {
345
+ let type = options?.type;
346
+ if (version === void 0 || type === void 0) {
274
347
  const item = await this.items.get(id);
275
- version = item.version;
348
+ version ??= item.version;
349
+ type ??= item.type;
276
350
  }
277
351
  const strategy = options?.conflict ?? this.defaultConflictStrategy;
352
+ const onAutoMerge = options?.onAutoMerge ?? this.defaultOnConflictAutoMerge;
278
353
  return handleConflictUpdate(
279
354
  this.transport,
280
355
  id,
356
+ type,
281
357
  properties,
282
358
  version,
283
359
  strategy,
284
360
  options?.resolve,
285
- options?.library
361
+ options?.library,
362
+ onAutoMerge
286
363
  );
287
364
  },
288
365
  delete: async (id) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mymehq/sdk",
3
- "version": "3.2.0",
3
+ "version": "3.3.1",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "registry": "https://registry.npmjs.org",
@@ -16,7 +16,7 @@
16
16
  "dist"
17
17
  ],
18
18
  "dependencies": {
19
- "@mymehq/shared": "3.2.0"
19
+ "@mymehq/shared": "3.3.1"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@types/node": "^22.0.0",