@mymehq/sdk 3.1.0 → 3.3.0

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
  }
@@ -45,6 +79,19 @@ interface UpdateOptions {
45
79
  conflict?: ConflictStrategy;
46
80
  /** Custom conflict resolver (required when `conflict` is `"callback"`). */
47
81
  resolve?: ConflictResolver;
82
+ /** Toggle library / ambient state. Independent of the version-merge path
83
+ * for `properties`; a library-only update never conflicts. */
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;
48
95
  }
49
96
  interface ListFilters {
50
97
  type?: string;
@@ -65,12 +112,33 @@ interface ListFilters {
65
112
  until?: string;
66
113
  limit?: number;
67
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;
68
129
  }
130
+ /** Item paired with its hydrated extension namespaces. */
131
+ type ItemWithExtensions = Item & {
132
+ extensions: Record<string, Record<string, unknown>>;
133
+ };
69
134
  interface SearchFilters {
70
135
  type?: string;
71
136
  state?: ItemState;
72
137
  /** Tri-value library filter, matching `ListFilters.library`. */
73
138
  library?: boolean;
139
+ /** Items must have ALL specified tags (AND semantics). Matches
140
+ * `ListFilters.tags` and `/items?tags=`. */
141
+ tags?: string[];
74
142
  filter?: string;
75
143
  limit?: number;
76
144
  }
@@ -80,6 +148,7 @@ interface MetadataInput {
80
148
  declare class MymeClient {
81
149
  private readonly transport;
82
150
  private readonly defaultConflictStrategy;
151
+ private readonly defaultOnConflictAutoMerge?;
83
152
  private readonly apiBaseUrl;
84
153
  private readonly cdnBaseUrl?;
85
154
  constructor(config: ClientConfig);
@@ -92,6 +161,14 @@ declare class MymeClient {
92
161
  get: (id: string) => Promise<Item>;
93
162
  list: (filters?: ListFilters) => Promise<PaginatedResult<Item>>;
94
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>>;
95
172
  update: (id: string, properties: Record<string, unknown>, options?: UpdateOptions) => Promise<Item>;
96
173
  delete: (id: string) => Promise<void>;
97
174
  /** Permanently delete a trashed item (admin only). Item must already
@@ -120,12 +197,33 @@ declare class MymeClient {
120
197
  merge: (itemId: string, input: Partial<MetadataInput>) => Promise<Metadata>;
121
198
  addTags: (itemId: string, tags: string[]) => Promise<Metadata>;
122
199
  removeTag: (itemId: string, tag: string) => Promise<void>;
200
+ /**
201
+ * Enumerate the distinct set of tags in use across items the caller can
202
+ * read. Tenant-scoped, type-permission scoped, excludes trashed items.
203
+ * Returns tags with usage counts, sorted by count desc then tag asc.
204
+ */
205
+ listTags: () => Promise<{
206
+ tag: string;
207
+ count: number;
208
+ }[]>;
123
209
  getExtensions: (itemId: string, namespace?: string) => Promise<Record<string, Record<string, unknown>>>;
124
210
  setExtension: (itemId: string, namespace: string, data: Record<string, unknown>) => Promise<Record<string, Record<string, unknown>>>;
125
211
  deleteExtension: (itemId: string, namespace: string) => Promise<void>;
126
212
  };
127
213
  search(query: string, filters?: SearchFilters): Promise<SearchResult[]>;
128
214
  readonly edges: {
215
+ /**
216
+ * Global edge listing across the tenant, filtered by edge type
217
+ * (comma-separated string or array of type ids). Use this when you
218
+ * need "all edges of type X" — replaces the walk-every-item
219
+ * pattern. Per-target filters live on `listFromSource` /
220
+ * `listToTarget`.
221
+ */
222
+ list: (filters?: {
223
+ edge_type?: string | string[];
224
+ limit?: number;
225
+ cursor?: string;
226
+ }) => Promise<PaginatedResult<Edge>>;
129
227
  /** Create a single edge. Server enforces cardinality / type
130
228
  * constraints / cycle prevention; throws on violation. */
131
229
  create: (input: CreateEdgeInput) => Promise<Edge>;
@@ -254,4 +352,4 @@ declare class ConflictError extends MymeError {
254
352
  constructor(current: ConflictSnapshot, ancestor: ConflictSnapshot, conflictingFields: string[], clientPatch: Record<string, unknown>);
255
353
  }
256
354
 
257
- 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,17 +200,22 @@ function toConflictError(response, clientPatch) {
173
200
  clientPatch
174
201
  );
175
202
  }
176
- async function handleConflictUpdate(transport, itemId, clientPatch, version, strategy, resolver) {
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: {
182
210
  properties,
183
- version: currentVersion
211
+ version: currentVersion,
212
+ ...library !== void 0 && { library }
184
213
  }
185
214
  });
186
215
  if (!isConflictResponse(result)) {
216
+ if (pendingAutoMergeEvent && onAutoMerge) {
217
+ await onAutoMerge(pendingAutoMergeEvent);
218
+ }
187
219
  return result.item;
188
220
  }
189
221
  if (strategy === "manual") {
@@ -196,10 +228,30 @@ async function handleConflictUpdate(transport, itemId, clientPatch, version, str
196
228
  current: result.current,
197
229
  ancestor: result.ancestor,
198
230
  conflictingFields: result.conflicting_fields,
199
- clientPatch
231
+ clientPatch,
232
+ mergePolicy: result.merge_policy
200
233
  };
201
234
  if (strategy === "auto") {
202
- 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
+ };
203
255
  } else {
204
256
  if (!resolver) {
205
257
  throw toConflictError(result, clientPatch);
@@ -220,6 +272,7 @@ async function handleConflictUpdate(transport, itemId, clientPatch, version, str
220
272
  var MymeClient = class {
221
273
  transport;
222
274
  defaultConflictStrategy;
275
+ defaultOnConflictAutoMerge;
223
276
  apiBaseUrl;
224
277
  cdnBaseUrl;
225
278
  constructor(config) {
@@ -231,6 +284,7 @@ var MymeClient = class {
231
284
  timeoutMs: config.timeoutMs
232
285
  });
233
286
  this.defaultConflictStrategy = config.conflictStrategy ?? "auto";
287
+ this.defaultOnConflictAutoMerge = config.onConflictAutoMerge;
234
288
  this.cdnBaseUrl = config.cdnBaseUrl;
235
289
  }
236
290
  // ---- Items ----
@@ -267,20 +321,45 @@ var MymeClient = class {
267
321
  }
268
322
  );
269
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
+ },
270
343
  update: async (id, properties, options) => {
271
344
  let version = options?.version;
272
- if (version === void 0) {
345
+ let type = options?.type;
346
+ if (version === void 0 || type === void 0) {
273
347
  const item = await this.items.get(id);
274
- version = item.version;
348
+ version ??= item.version;
349
+ type ??= item.type;
275
350
  }
276
351
  const strategy = options?.conflict ?? this.defaultConflictStrategy;
352
+ const onAutoMerge = options?.onAutoMerge ?? this.defaultOnConflictAutoMerge;
277
353
  return handleConflictUpdate(
278
354
  this.transport,
279
355
  id,
356
+ type,
280
357
  properties,
281
358
  version,
282
359
  strategy,
283
- options?.resolve
360
+ options?.resolve,
361
+ options?.library,
362
+ onAutoMerge
284
363
  );
285
364
  },
286
365
  delete: async (id) => {
@@ -366,6 +445,15 @@ var MymeClient = class {
366
445
  `/items/${itemId}/tags/${encodeURIComponent(tag)}`
367
446
  );
368
447
  },
448
+ /**
449
+ * Enumerate the distinct set of tags in use across items the caller can
450
+ * read. Tenant-scoped, type-permission scoped, excludes trashed items.
451
+ * Returns tags with usage counts, sorted by count desc then tag asc.
452
+ */
453
+ listTags: async () => {
454
+ const res = await this.transport.request("GET", "/metadata/tags");
455
+ return res.tags;
456
+ },
369
457
  getExtensions: async (itemId, namespace) => {
370
458
  if (namespace) {
371
459
  const res2 = await this.transport.request(
@@ -403,6 +491,23 @@ var MymeClient = class {
403
491
  }
404
492
  // ---- Edges ----
405
493
  edges = {
494
+ /**
495
+ * Global edge listing across the tenant, filtered by edge type
496
+ * (comma-separated string or array of type ids). Use this when you
497
+ * need "all edges of type X" — replaces the walk-every-item
498
+ * pattern. Per-target filters live on `listFromSource` /
499
+ * `listToTarget`.
500
+ */
501
+ list: async (filters) => {
502
+ const edgeType = Array.isArray(filters?.edge_type) ? filters.edge_type.join(",") : filters?.edge_type;
503
+ return this.transport.request("GET", "/edges", {
504
+ query: {
505
+ ...edgeType && { edge_type: edgeType },
506
+ ...filters?.limit !== void 0 && { limit: filters.limit },
507
+ ...filters?.cursor && { cursor: filters.cursor }
508
+ }
509
+ });
510
+ },
406
511
  /** Create a single edge. Server enforces cardinality / type
407
512
  * constraints / cycle prevention; throws on violation. */
408
513
  create: async (input) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mymehq/sdk",
3
- "version": "3.1.0",
3
+ "version": "3.3.0",
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.1.0"
19
+ "@mymehq/shared": "3.3.0"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@types/node": "^22.0.0",