@tallyui/connector-woocommerce 2.0.0 → 3.0.0-next.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/README.md ADDED
@@ -0,0 +1,40 @@
1
+ # TallyUI WooCommerce connector
2
+
3
+ ## Requirements
4
+
5
+ - **WooCommerce 5.8 or later.** The product pull filters by modification date with `modified_after`, added in WooCommerce 5.8, and compares it in GMT with `dates_are_gmt`, added in 5.4.
6
+ - **The WCPOS Free plugin, 1.10.0 or later.** The connector reads through its `wcpos/v2` routes, which add each product's `uuid`. Set `SyncContext.baseUrl` to `<site>/wp-json/wcpos/v2`.
7
+
8
+ ## Authentication
9
+
10
+ `auth.getHeaders({ token })` sends the WCPOS access token as `Authorization: Bearer <token>`, together with the POS marker `X-WCPOS: 1`. Without a token it throws `WooMissingTokenError`. WooCommerce consumer keys are not supported.
11
+
12
+ It also sends `X-WCPOS-Protocol: 2`, which WCPOS 2.0 requires of POS requests, and `X-WCPOS-Client: tallyui/<connector version>` (lowercase `[a-z0-9._-]`, at most 32 characters), which WCPOS uses only for consent-gated telemetry.
13
+
14
+ ## Product pull
15
+
16
+ The pull works in passes:
17
+ - a small request finds the store's newest `date_modified_gmt`;
18
+ - the pass then pages every product modified since the last pass, by id with an offset;
19
+ - if a product leaves the window mid-pass (read from `X-WP-Total`), the pass restarts.
20
+
21
+ When nothing has changed, a poll costs one request. Products whose `status` is not `publish` reach the till as deletions.
22
+
23
+ Dates are sent as GMT digits **without** a timezone offset, together with `dates_are_gmt=true`. WordPress parses an offset-bearing date in the site's timezone before it compares it with the GMT column, so an explicit offset would shift the filter by the site's UTC offset.
24
+
25
+ ## Reconciliation
26
+
27
+ The incremental pull can miss an edit: a `modified_after` that over-excludes (including a bound in the site's spring-forward hour), an edit in the same second as the last mark, a removal mid-pass on a store that hides `X-WP-Total`, a trashed product, or a stock change written without a modified-time bump. `reconcile.catalogue` is the daily safety net for all of them. Run it with `@tallyui/database`'s `startCatalogueReconcile`, beside the product replication, on the same collection.
28
+ - It reads `wcpos/v2/status` once per pass, then lists every published product (`status=publish`, `_fields=id,uuid,date_modified_gmt,stock_quantity,stock_status`, 100 per page) with no date filter. A failed status read only means no capability is known; a rejected token (401, 403) or a till that needs updating (426) stops the pass.
29
+ - A product whose `(date_modified_gmt, stock_quantity, stock_status)` differs from the till's copy, or that the till lacks, is refetched by id through the product pull (`replication.products` combines the product feed with a reconcile feed). Nothing is written locally.
30
+ - A product the till holds but the listing omits is removed only when a by-id re-read (`include=`, `status=any`) finds it gone, trashed or unpublished, and within the runner's mass-delete brake.
31
+ - A product hidden from the POS after it synced (WCPOS "online only" visibility, with POS-only products turned on) is removed from the till by the pass; hiding many at once is held by the mass-delete brake.
32
+ - An existing install keeps its pull checkpoint: the combined pull reads it under `legacyKey: 'products'`.
33
+ - The WCPOS bulk-id fast path is dormant: it is chosen only when `status.capabilities` includes `products_id_fast_path` (wcpos/woocommerce-pos#2113), and until its id-to-uuid mapping exists the listing stays paged.
34
+
35
+ ## Errors
36
+
37
+ - `ConnectorUnauthorizedError` (`code: 'unauthorized'`), from `@tallyui/core` and re-exported here: the store rejected the token (401 or 403), or `WooMissingTokenError` (a subclass) was thrown for a missing token. The app should sign in again.
38
+ - `WooDateFilterError` (`code: 'unsupported_store'`): the store returned a product outside the requested date window, so it does not honour `modified_after` (WooCommerce before 5.8, or a proxy that drops the parameter). An app should show its own words for the `code`; the message, `This store needs WooCommerce 5.8 or later to sync products.`, is a plain fallback. The diagnostics are in `productId`, `bound` (the `modified_after` sent) and `received`. Today the pull retries a failed request every 5 s (RxDB's `retryTime`), one mark request each time. Stopping on errors that cannot recover is shared replication work.
39
+ - `WooTillUpdateRequiredError` (`code: 'till_update_required'`, `fixedBy: 'till'`): the store answered 426, normally WCPOS's protocol gate (`serverCode: 'wcpos_update_required'`), so this till must be updated; the pull pauses until `resume()`.
40
+ - `WooMissingUuidError`: a product came back without a `uuid`, so the store is not running the WCPOS Free plugin 1.10.0 or later, or the connector is not reaching it through `wcpos/v2`.
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { ProductTraits, CollectionSync, ReplicationAdapter, TallyConnector } from '@tallyui/core';
1
+ import { ProductTraits, CollectionSync, ReplicationAdapter, ReconcileFeed, CatalogueReconcileAdapter, ConnectorUnauthorizedError, TallyConnector } from '@tallyui/core';
2
+ export { ConnectorUnauthorizedError } from '@tallyui/core';
2
3
  import { RxJsonSchema } from 'rxdb';
3
4
 
4
5
  /**
@@ -26,33 +27,101 @@ declare const wooProductTraits: ProductTraits;
26
27
  declare const wooProductSync: CollectionSync;
27
28
 
28
29
  type WooProductCheckpoint = {
29
- id: string;
30
+ /** Inclusive lower bound (date_modified_gmt) of the current pass; '' = the whole catalogue. */
30
31
  modified: string;
32
+ /** Rows of the current pass already returned, in id order. */
33
+ offset: number;
34
+ /** Newest date_modified_gmt in the store when the pass started; the next pass's lower bound. */
35
+ pass_mark?: string;
36
+ /** X-WP-Total of the window on the last page; undefined when the store sends none. */
37
+ pass_count?: number;
31
38
  };
39
+ /** WCPOS's protocol gate refused this till (HTTP 426 with `wcpos_update_required`): only updating the till fixes it. */
40
+ declare class WooTillUpdateRequiredError extends Error {
41
+ readonly serverCode: string | undefined;
42
+ name: string;
43
+ readonly code: "till_update_required";
44
+ /** Only updating this till fixes it, so the pull pauses until resume() (`errorKind`). */
45
+ readonly fixedBy: "till";
46
+ /** The body's `code`, kept for diagnostics. */
47
+ constructor(serverCode: string | undefined);
48
+ }
49
+ declare class WooMissingUuidError extends Error {
50
+ name: string;
51
+ readonly code: "missing_plugin";
52
+ /** Only the store owner can fix it, by installing or enabling the plugin, so the pull waits the store delay (`errorKind`). */
53
+ readonly fixedBy: "store";
54
+ productId: number;
55
+ constructor(id: number);
56
+ }
57
+ /** The store returned a product outside a modified_after window, so it does not apply the filter. */
58
+ declare class WooDateFilterError extends Error {
59
+ readonly productId: number | undefined;
60
+ readonly bound: string;
61
+ readonly received: string | undefined;
62
+ name: string;
63
+ readonly code: "unsupported_store";
64
+ /** Only the store owner can fix it, by updating WooCommerce, so the pull waits the store delay (`errorKind`). */
65
+ readonly fixedBy: "store";
66
+ /** The software to update, for `SyncStatus`'s detail; the component never names it itself. */
67
+ readonly software = "WooCommerce";
68
+ /** The first WooCommerce version that applies `modified_after` (5.8). */
69
+ readonly minVersion = "5.8";
70
+ constructor(productId: number | undefined, bound: string, received: string | undefined);
71
+ }
32
72
  /**
33
73
  * Replication adapter for WooCommerce products.
34
74
  *
35
- * Pull-only (cursor-based pagination by date_modified_gmt) for RxDB's
75
+ * Pull-only (id-ordered offset pages within a date_modified_gmt window) for RxDB's
36
76
  * replicateRxCollection. Catalogue data is server-owned; the POS never
37
77
  * writes products.
38
78
  */
39
79
  declare const wooProductReplication: ReplicationAdapter<any, WooProductCheckpoint>;
40
80
 
41
81
  /**
42
- * WooCommerce connector for Tally UI.
82
+ * What the catalogue reconcile compares: the modified time and the stock (#248 A1). WooCommerce's
83
+ * `update_product_stock()` writes `_stock` by a direct query that leaves the modified time alone.
84
+ */
85
+ declare const wooReconcileFingerprint: (p: {
86
+ date_modified_gmt?: string | null;
87
+ stock_quantity?: number | null;
88
+ stock_status?: string | null;
89
+ }) => string;
90
+ /**
91
+ * The daily catalogue check for WooCommerce (#248): lists every published product with its fingerprint,
92
+ * page by page with no date filter, and hands differences to `feed`, which reaches the collection only
93
+ * through the pull. `confirmGone` is the deletion proof: a by-id re-read.
94
+ */
95
+ declare function wooCatalogueReconcile(feed: Pick<ReconcileFeed<any>, 'enqueue'>): CatalogueReconcileAdapter<any, number>;
96
+
97
+ /** One part of X-WCPOS-Client as WCPOS keeps it: lowercase [a-z0-9._-], at most 32 characters. */
98
+ declare function wcposClientPart(part: string): string;
99
+ declare class WooMissingTokenError extends ConnectorUnauthorizedError {
100
+ constructor();
101
+ }
102
+ /**
103
+ * WooCommerce connector for Tally UI. Build one per store session, anew on each sign-in or store change:
104
+ * it owns the reconcile feed, whose queued work must never reach another store's database (#307).
43
105
  *
44
- * Connects to WooCommerce sites via the REST API (v3) or WCPOS API.
106
+ * Requires the WCPOS Free plugin 1.10.0 or later. SyncContext.baseUrl must
107
+ * be the store's wcpos/v2 root: <site>/wp-json/wcpos/v2.
45
108
  * Products are stored in RxDB using a schema that mirrors the WC API shape.
46
109
  *
47
110
  * ```ts
48
- * import { woocommerceConnector } from '@tallyui/connector-woocommerce';
111
+ * import { createWooCommerceConnector } from '@tallyui/connector-woocommerce';
49
112
  * import { ConnectorProvider } from '@tallyui/core';
50
113
  *
51
- * <ConnectorProvider connector={woocommerceConnector}>
114
+ * const connector = useMemo(() => createWooCommerceConnector(), [storeUrl]);
115
+ * <ConnectorProvider connector={connector}>
52
116
  * <App />
53
117
  * </ConnectorProvider>
54
118
  * ```
55
119
  */
120
+ declare function createWooCommerceConnector(): TallyConnector;
121
+ /**
122
+ * @deprecated One instance for the whole app: a store switch can leak queued reconcile work across stores.
123
+ * Use createWooCommerceConnector() per store session. Removed in 4.0.
124
+ */
56
125
  declare const woocommerceConnector: TallyConnector;
57
126
 
58
- export { wooProductReplication, wooProductSchema, wooProductSync, wooProductTraits, woocommerceConnector };
127
+ export { WooDateFilterError, WooMissingTokenError, WooMissingUuidError, WooTillUpdateRequiredError, createWooCommerceConnector, wcposClientPart, wooCatalogueReconcile, wooProductReplication, wooProductSchema, wooProductSync, wooProductTraits, wooReconcileFingerprint, woocommerceConnector };
package/dist/index.js CHANGED
@@ -1,3 +1,6 @@
1
+ // src/index.ts
2
+ import { combinePullAdapters, ConnectorUnauthorizedError as ConnectorUnauthorizedError2 } from "@tallyui/core";
3
+
1
4
  // src/schemas/products.ts
2
5
  var wooProductSchema = {
3
6
  title: "WooCommerce Product",
@@ -227,8 +230,13 @@ var wooProductSync = {
227
230
  return response.json();
228
231
  },
229
232
  async fetchModifiedAfter(date, context) {
233
+ const params = new URLSearchParams({
234
+ modified_after: date,
235
+ dates_are_gmt: "true",
236
+ per_page: "100"
237
+ });
230
238
  const response = await fetch(
231
- `${context.baseUrl}/products?modified_after=${date}&per_page=100`,
239
+ `${context.baseUrl}/products?${params}`,
232
240
  {
233
241
  headers: {
234
242
  ...context.headers,
@@ -245,17 +253,98 @@ var wooProductSync = {
245
253
  };
246
254
 
247
255
  // src/replication/products.ts
256
+ import { ConnectorUnauthorizedError } from "@tallyui/core";
257
+ var MAX_REQUESTS_PER_CALL = 4;
258
+ var MAX_STORE_MESSAGE = 200;
259
+ async function checkResponse(response) {
260
+ if (response.ok) return;
261
+ if (response.status === 401 || response.status === 403) {
262
+ throw new ConnectorUnauthorizedError(`WooCommerce API error: ${response.status}`, response.status);
263
+ }
264
+ if (response.status === 426) {
265
+ const body = await response.json().catch(() => void 0);
266
+ if (body?.code === "wcpos_update_required") throw new WooTillUpdateRequiredError(body.code);
267
+ const code = typeof body?.code === "string" && body.code ? ` (${body.code})` : "";
268
+ const text = typeof body?.message === "string" ? body.message : "";
269
+ const message = text ? `: ${text.length > MAX_STORE_MESSAGE ? `${text.slice(0, MAX_STORE_MESSAGE)}\u2026` : text}` : "";
270
+ throw new Error(`WooCommerce API error: 426${code}${message}`);
271
+ }
272
+ throw new Error(`WooCommerce API error: ${response.status}`);
273
+ }
274
+ var WooTillUpdateRequiredError = class extends Error {
275
+ /** The body's `code`, kept for diagnostics. */
276
+ constructor(serverCode) {
277
+ super("WooCommerce API error: 426: this till needs updating to sync with the store");
278
+ this.serverCode = serverCode;
279
+ }
280
+ name = "WooTillUpdateRequiredError";
281
+ code = "till_update_required";
282
+ /** Only updating this till fixes it, so the pull pauses until resume() (`errorKind`). */
283
+ fixedBy = "till";
284
+ };
285
+ var WooMissingUuidError = class extends Error {
286
+ name = "WooMissingUuidError";
287
+ code = "missing_plugin";
288
+ /** Only the store owner can fix it, by installing or enabling the plugin, so the pull waits the store delay (`errorKind`). */
289
+ fixedBy = "store";
290
+ productId;
291
+ constructor(id) {
292
+ super(`WooCommerce product ${id} has no uuid: the store must run the WCPOS Free plugin (1.10.0 or later) and be reached through its wcpos/v2 routes`);
293
+ this.productId = id;
294
+ }
295
+ };
296
+ function toProductDocument(product) {
297
+ if (typeof product.uuid !== "string" || product.uuid.length === 0) throw new WooMissingUuidError(product.id);
298
+ return { ...product, _deleted: product.status !== "publish" };
299
+ }
300
+ var WooDateFilterError = class extends Error {
301
+ constructor(productId, bound, received) {
302
+ super("This store needs WooCommerce 5.8 or later to sync products.");
303
+ this.productId = productId;
304
+ this.bound = bound;
305
+ this.received = received;
306
+ }
307
+ name = "WooDateFilterError";
308
+ code = "unsupported_store";
309
+ /** Only the store owner can fix it, by updating WooCommerce, so the pull waits the store delay (`errorKind`). */
310
+ fixedBy = "store";
311
+ /** The software to update, for `SyncStatus`'s detail; the component never names it itself. */
312
+ software = "WooCommerce";
313
+ /** The first WooCommerce version that applies `modified_after` (5.8). */
314
+ minVersion = "5.8";
315
+ };
248
316
  var wooProductReplication = {
249
317
  pull: {
250
- async handler(lastCheckpoint, batchSize, context) {
318
+ handler: async function pull(lastCheckpoint, batchSize, context, requests = 0) {
319
+ const modified = lastCheckpoint?.modified ?? "";
320
+ const offset = lastCheckpoint?.offset ?? 0;
321
+ const spent = { documents: [], checkpoint: lastCheckpoint ?? { modified, offset } };
322
+ let passMark = lastCheckpoint?.pass_mark ?? modified;
323
+ if (offset === 0 && lastCheckpoint?.pass_mark === void 0) {
324
+ if (requests++ >= MAX_REQUESTS_PER_CALL) return spent;
325
+ const newer = modified ? { modified_after: modified, dates_are_gmt: "true" } : {};
326
+ const mark = new URLSearchParams({ per_page: "1", orderby: "modified", order: "desc", ...newer });
327
+ const markResponse = await fetch(
328
+ `${context.baseUrl}/products?${mark}`,
329
+ { headers: { ...context.headers, "Content-Type": "application/json" }, signal: context.signal }
330
+ );
331
+ await checkResponse(markResponse);
332
+ const [newest] = await markResponse.json();
333
+ if (newest === void 0) return { documents: [], checkpoint: lastCheckpoint ?? { modified: "", offset: 0 } };
334
+ if (modified && !(newest.date_modified_gmt > modified)) throw new WooDateFilterError(newest.id, modified, newest.date_modified_gmt);
335
+ passMark = newest.date_modified_gmt ?? modified;
336
+ }
251
337
  const params = new URLSearchParams({
252
338
  per_page: String(batchSize),
253
- orderby: "modified",
339
+ offset: String(offset),
340
+ orderby: "id",
254
341
  order: "asc"
255
342
  });
256
- if (lastCheckpoint?.modified) {
257
- params.set("modified_after", lastCheckpoint.modified);
343
+ if (modified) {
344
+ params.set("modified_after", new Date(Date.parse(modified + "Z") - 1e3).toISOString().slice(0, 19));
345
+ params.set("dates_are_gmt", "true");
258
346
  }
347
+ if (requests++ >= MAX_REQUESTS_PER_CALL) return spent;
259
348
  const response = await fetch(`${context.baseUrl}/products?${params}`, {
260
349
  headers: {
261
350
  ...context.headers,
@@ -263,29 +352,154 @@ var wooProductReplication = {
263
352
  },
264
353
  signal: context.signal
265
354
  });
266
- if (!response.ok) {
267
- throw new Error(`WooCommerce API error: ${response.status}`);
268
- }
355
+ await checkResponse(response);
269
356
  const products = await response.json();
270
- const documents = products.map((p) => ({ ...p, _deleted: false }));
271
- const checkpoint = products.length > 0 ? {
272
- id: String(products[products.length - 1].uuid ?? products[products.length - 1].id),
273
- modified: products[products.length - 1].date_modified_gmt
274
- } : lastCheckpoint ?? { id: "", modified: "" };
357
+ const total = response.headers.get("X-WP-Total");
358
+ const count = total !== null && /^\d+$/.test(total) ? Number(total) : void 0;
359
+ if (offset > 0 && count !== void 0 && (products.length === 0 || count < (lastCheckpoint?.pass_count ?? count))) {
360
+ return pull({ modified, offset: 0, pass_mark: passMark }, batchSize, context, requests);
361
+ }
362
+ const documents = products.map((product) => {
363
+ const document = toProductDocument(product);
364
+ if (modified && !(product.date_modified_gmt >= modified)) {
365
+ throw new WooDateFilterError(product.id, params.get("modified_after"), product.date_modified_gmt);
366
+ }
367
+ return document;
368
+ });
369
+ const complete = count === void 0 ? products.length < batchSize : offset + products.length >= count;
370
+ const checkpoint = complete ? { modified: passMark, offset: 0, pass_mark: void 0, pass_count: void 0 } : { modified, offset: offset + products.length, pass_mark: passMark, pass_count: count };
371
+ if (complete && products.length === 0) {
372
+ const next = await pull({ modified: passMark, offset: 0 }, batchSize, context, requests);
373
+ return next.documents.length > 0 ? next : { documents: [], checkpoint: lastCheckpoint ?? checkpoint };
374
+ }
275
375
  return { documents, checkpoint };
276
376
  }
277
377
  }
278
378
  };
279
379
 
380
+ // src/reconcile/catalogue.ts
381
+ import { errorKind } from "@tallyui/core";
382
+ var PAGE_SIZE = 100;
383
+ var LISTING_FIELDS = "id,uuid,date_modified_gmt,stock_quantity,stock_status";
384
+ var ID_FAST_PATH = "products_id_fast_path";
385
+ var wooReconcileFingerprint = (p) => [p.date_modified_gmt ?? "", p.stock_quantity ?? "", p.stock_status ?? ""].join("|");
386
+ async function get(path, context) {
387
+ const response = await fetch(`${context.baseUrl}${path}`, {
388
+ headers: { ...context.headers, "Content-Type": "application/json" },
389
+ signal: context.signal
390
+ });
391
+ await checkResponse(response);
392
+ return response.json();
393
+ }
394
+ async function wooHasIdFastPath(context) {
395
+ let status;
396
+ try {
397
+ status = await get("/status", context);
398
+ } catch (error) {
399
+ if (errorKind(error) === "till" || context.signal?.aborted) throw error;
400
+ return false;
401
+ }
402
+ return Array.isArray(status?.capabilities) && status.capabilities.includes(ID_FAST_PATH);
403
+ }
404
+ function wooCatalogueReconcile(feed) {
405
+ return {
406
+ async *fetchPages(context, from = 1) {
407
+ const fast = await wooHasIdFastPath(context);
408
+ yield { entries: [], cursor: from };
409
+ if (fast) {
410
+ }
411
+ for (let page = from; ; page++) {
412
+ const params = new URLSearchParams({
413
+ per_page: String(PAGE_SIZE),
414
+ page: String(page),
415
+ orderby: "id",
416
+ order: "asc",
417
+ status: "publish",
418
+ _fields: LISTING_FIELDS
419
+ });
420
+ const rows = await get(`/products?${params}`, context);
421
+ const entries = rows.map((row) => {
422
+ if (typeof row.uuid !== "string" || row.uuid.length === 0) throw new WooMissingUuidError(row.id);
423
+ return { key: row.uuid, fingerprint: wooReconcileFingerprint(row), remote: row.id };
424
+ });
425
+ yield { entries, cursor: page + 1 };
426
+ if (rows.length < PAGE_SIZE) return;
427
+ }
428
+ },
429
+ fingerprint: wooReconcileFingerprint,
430
+ async confirmGone(locals, context) {
431
+ const ids = locals.map((doc) => doc.id).filter((id) => Number.isInteger(id));
432
+ if (!ids.length) return [];
433
+ const params = new URLSearchParams({ include: ids.join(","), per_page: String(PAGE_SIZE), status: "any", _fields: "id,uuid,status" });
434
+ const rows = await get(`/products?${params}`, context);
435
+ const live = new Set(rows.filter((row) => row.status === "publish").map((row) => row.id));
436
+ return locals.filter((doc) => Number.isInteger(doc.id) && !live.has(doc.id)).map((doc) => doc.uuid);
437
+ },
438
+ // An entry with no numeric id, locally or in the listing, is dropped: the store cannot be asked about it, so
439
+ // the feed would find nothing and tombstone it without proof. (The schema requires only uuid.)
440
+ enqueue: (entries) => feed.enqueue(entries.filter((e) => e.tombstone || Number.isInteger(e.local?.id) || Number.isInteger(e.remote)))
441
+ };
442
+ }
443
+
444
+ // src/reconcile/feed.ts
445
+ import { createReconcileFeed } from "@tallyui/core";
446
+ var MAX_IDS_PER_REQUEST = 100;
447
+ async function wooFetchByIds(entries, context) {
448
+ const ids = entries.map((e) => e.local?.id ?? e.remote).filter((id) => Number.isInteger(id));
449
+ const documents = [];
450
+ for (let i = 0; i < ids.length; i += MAX_IDS_PER_REQUEST) {
451
+ const chunk = ids.slice(i, i + MAX_IDS_PER_REQUEST);
452
+ const params = new URLSearchParams({ include: chunk.join(","), per_page: String(MAX_IDS_PER_REQUEST), status: "any" });
453
+ const response = await fetch(`${context.baseUrl}/products?${params}`, {
454
+ headers: { ...context.headers, "Content-Type": "application/json" },
455
+ signal: context.signal
456
+ });
457
+ await checkResponse(response);
458
+ documents.push(...(await response.json()).map(toProductDocument));
459
+ }
460
+ return documents;
461
+ }
462
+ function createWooReconcileFeed() {
463
+ return createReconcileFeed({ key: (doc) => doc.uuid, fetchByIds: wooFetchByIds });
464
+ }
465
+
466
+ // package.json
467
+ var version = "3.0.0-next.0";
468
+
280
469
  // src/index.ts
281
- var woocommerceConnector = {
470
+ import { ConnectorUnauthorizedError as ConnectorUnauthorizedError3 } from "@tallyui/core";
471
+ function wcposClientPart(part) {
472
+ return part.toLowerCase().replace(/[^a-z0-9._-]/g, "").slice(0, 32);
473
+ }
474
+ var WooMissingTokenError = class extends ConnectorUnauthorizedError2 {
475
+ constructor() {
476
+ super("WooCommerce credentials have no WCPOS access token: sign in again");
477
+ this.name = "WooMissingTokenError";
478
+ }
479
+ };
480
+ function createWooCommerceConnector() {
481
+ const catalogueFeed = createWooReconcileFeed();
482
+ return {
483
+ ...wooConnectorParts,
484
+ replication: {
485
+ // One replication per collection; the reconcile feed is last, so its fetch wins duplicates. legacyKey
486
+ // reads an existing install's plain pull checkpoint as the product feed's, so it does not resync.
487
+ products: combinePullAdapters({ products: wooProductReplication, reconcile: catalogueFeed.adapter }, { legacyKey: "products" })
488
+ },
489
+ reconcile: {
490
+ // The feed's fetchByIds asks for at most this many ids per request; the runner budgets by requests.
491
+ catalogue: { ...wooCatalogueReconcile(catalogueFeed), refetchBatchSize: MAX_IDS_PER_REQUEST }
492
+ }
493
+ };
494
+ }
495
+ var wooConnectorParts = {
282
496
  id: "woocommerce",
283
497
  name: "WooCommerce",
284
498
  description: "Connect to WooCommerce stores via the REST API",
285
499
  icon: void 0,
286
500
  // TODO: WooCommerce logo
287
501
  auth: {
288
- type: "WooCommerce REST API",
502
+ type: "WCPOS token",
289
503
  fields: [
290
504
  {
291
505
  key: "url",
@@ -295,24 +509,23 @@ var woocommerceConnector = {
295
509
  required: true
296
510
  },
297
511
  {
298
- key: "consumer_key",
299
- label: "Consumer Key",
300
- type: "text",
301
- placeholder: "ck_...",
302
- required: true
303
- },
304
- {
305
- key: "consumer_secret",
306
- label: "Consumer Secret",
512
+ key: "token",
513
+ label: "Access token",
307
514
  type: "password",
308
- placeholder: "cs_...",
309
515
  required: true
310
516
  }
311
517
  ],
312
518
  getHeaders: (credentials) => {
313
- const encoded = btoa(`${credentials.consumer_key}:${credentials.consumer_secret}`);
519
+ if (typeof credentials.token !== "string" || credentials.token.length === 0) {
520
+ throw new WooMissingTokenError();
521
+ }
314
522
  return {
315
- Authorization: `Basic ${encoded}`
523
+ Authorization: `Bearer ${credentials.token}`,
524
+ "X-WCPOS": "1",
525
+ // WCPOS 2.0 refuses a POS request below protocol 2 (HTTP 426); the connector already speaks 2.
526
+ "X-WCPOS-Protocol": "2",
527
+ // For WCPOS's consent-gated telemetry only.
528
+ "X-WCPOS-Client": `tallyui/${wcposClientPart(version)}`
316
529
  };
317
530
  }
318
531
  },
@@ -324,15 +537,22 @@ var woocommerceConnector = {
324
537
  },
325
538
  sync: {
326
539
  products: wooProductSync
327
- },
328
- replication: {
329
- products: wooProductReplication
330
540
  }
331
541
  };
542
+ var woocommerceConnector = createWooCommerceConnector();
332
543
  export {
544
+ ConnectorUnauthorizedError3 as ConnectorUnauthorizedError,
545
+ WooDateFilterError,
546
+ WooMissingTokenError,
547
+ WooMissingUuidError,
548
+ WooTillUpdateRequiredError,
549
+ createWooCommerceConnector,
550
+ wcposClientPart,
551
+ wooCatalogueReconcile,
333
552
  wooProductReplication,
334
553
  wooProductSchema,
335
554
  wooProductSync,
336
555
  wooProductTraits,
556
+ wooReconcileFingerprint,
337
557
  woocommerceConnector
338
558
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tallyui/connector-woocommerce",
3
- "version": "2.0.0",
3
+ "version": "3.0.0-next.0",
4
4
  "type": "module",
5
5
  "description": "WooCommerce connector for Tally UI",
6
6
  "main": "./dist/index.js",
@@ -16,7 +16,8 @@
16
16
  "files": [
17
17
  "dist",
18
18
  "src",
19
- "!src/**/*.test.ts"
19
+ "!src/**/*.test.ts",
20
+ "!src/**/__tests__/**"
20
21
  ],
21
22
  "license": "MIT",
22
23
  "repository": {
@@ -25,10 +26,11 @@
25
26
  "directory": "connectors/woocommerce"
26
27
  },
27
28
  "peerDependencies": {
28
- "@tallyui/core": "2.0.0"
29
+ "@tallyui/core": "^3.0.0-next.0"
29
30
  },
30
31
  "devDependencies": {
31
- "rxdb": "16.21.1"
32
+ "@tallyui/database": "3.0.0-next.0",
33
+ "rxdb": "17.5.0"
32
34
  },
33
35
  "scripts": {
34
36
  "build": "tsup",
package/src/index.ts CHANGED
@@ -1,33 +1,69 @@
1
- import type { TallyConnector } from '@tallyui/core';
1
+ import { combinePullAdapters, ConnectorUnauthorizedError, type TallyConnector } from '@tallyui/core';
2
2
 
3
3
  import { wooProductSchema } from './schemas/products';
4
4
  import { wooProductTraits } from './traits/product';
5
5
  import { wooProductSync } from './sync/products';
6
6
  import { wooProductReplication } from './replication/products';
7
+ import { wooCatalogueReconcile } from './reconcile/catalogue';
8
+ import { createWooReconcileFeed, MAX_IDS_PER_REQUEST } from './reconcile/feed';
9
+ import { version } from '../package.json';
10
+
11
+ /** One part of X-WCPOS-Client as WCPOS keeps it: lowercase [a-z0-9._-], at most 32 characters. */
12
+ export function wcposClientPart(part: string): string {
13
+ return part.toLowerCase().replace(/[^a-z0-9._-]/g, '').slice(0, 32);
14
+ }
15
+
16
+ export class WooMissingTokenError extends ConnectorUnauthorizedError {
17
+ constructor() {
18
+ super('WooCommerce credentials have no WCPOS access token: sign in again');
19
+ this.name = 'WooMissingTokenError';
20
+ }
21
+ }
7
22
 
8
23
  /**
9
- * WooCommerce connector for Tally UI.
24
+ * WooCommerce connector for Tally UI. Build one per store session, anew on each sign-in or store change:
25
+ * it owns the reconcile feed, whose queued work must never reach another store's database (#307).
10
26
  *
11
- * Connects to WooCommerce sites via the REST API (v3) or WCPOS API.
27
+ * Requires the WCPOS Free plugin 1.10.0 or later. SyncContext.baseUrl must
28
+ * be the store's wcpos/v2 root: <site>/wp-json/wcpos/v2.
12
29
  * Products are stored in RxDB using a schema that mirrors the WC API shape.
13
30
  *
14
31
  * ```ts
15
- * import { woocommerceConnector } from '@tallyui/connector-woocommerce';
32
+ * import { createWooCommerceConnector } from '@tallyui/connector-woocommerce';
16
33
  * import { ConnectorProvider } from '@tallyui/core';
17
34
  *
18
- * <ConnectorProvider connector={woocommerceConnector}>
35
+ * const connector = useMemo(() => createWooCommerceConnector(), [storeUrl]);
36
+ * <ConnectorProvider connector={connector}>
19
37
  * <App />
20
38
  * </ConnectorProvider>
21
39
  * ```
22
40
  */
23
- export const woocommerceConnector: TallyConnector = {
41
+ export function createWooCommerceConnector(): TallyConnector {
42
+ // The catalogue reconcile's corrections reach `products` only through this pull adapter (#248).
43
+ const catalogueFeed = createWooReconcileFeed();
44
+ return {
45
+ ...wooConnectorParts,
46
+ replication: {
47
+ // One replication per collection; the reconcile feed is last, so its fetch wins duplicates. legacyKey
48
+ // reads an existing install's plain pull checkpoint as the product feed's, so it does not resync.
49
+ products: combinePullAdapters({ products: wooProductReplication, reconcile: catalogueFeed.adapter }, { legacyKey: 'products' }),
50
+ },
51
+ reconcile: {
52
+ // The feed's fetchByIds asks for at most this many ids per request; the runner budgets by requests.
53
+ catalogue: { ...wooCatalogueReconcile(catalogueFeed), refetchBatchSize: MAX_IDS_PER_REQUEST },
54
+ },
55
+ };
56
+ }
57
+
58
+ // Everything but the reconcile feed and what is wired to it: stateless, so every instance shares it.
59
+ const wooConnectorParts = {
24
60
  id: 'woocommerce',
25
61
  name: 'WooCommerce',
26
62
  description: 'Connect to WooCommerce stores via the REST API',
27
63
  icon: undefined, // TODO: WooCommerce logo
28
64
 
29
65
  auth: {
30
- type: 'WooCommerce REST API',
66
+ type: 'WCPOS token',
31
67
  fields: [
32
68
  {
33
69
  key: 'url',
@@ -37,24 +73,23 @@ export const woocommerceConnector: TallyConnector = {
37
73
  required: true,
38
74
  },
39
75
  {
40
- key: 'consumer_key',
41
- label: 'Consumer Key',
42
- type: 'text',
43
- placeholder: 'ck_...',
44
- required: true,
45
- },
46
- {
47
- key: 'consumer_secret',
48
- label: 'Consumer Secret',
76
+ key: 'token',
77
+ label: 'Access token',
49
78
  type: 'password',
50
- placeholder: 'cs_...',
51
79
  required: true,
52
80
  },
53
81
  ],
54
82
  getHeaders: (credentials) => {
55
- const encoded = btoa(`${credentials.consumer_key}:${credentials.consumer_secret}`);
83
+ if (typeof credentials.token !== 'string' || credentials.token.length === 0) {
84
+ throw new WooMissingTokenError();
85
+ }
56
86
  return {
57
- Authorization: `Basic ${encoded}`,
87
+ Authorization: `Bearer ${credentials.token}`,
88
+ 'X-WCPOS': '1',
89
+ // WCPOS 2.0 refuses a POS request below protocol 2 (HTTP 426); the connector already speaks 2.
90
+ 'X-WCPOS-Protocol': '2',
91
+ // For WCPOS's consent-gated telemetry only.
92
+ 'X-WCPOS-Client': `tallyui/${wcposClientPart(version)}`,
58
93
  };
59
94
  },
60
95
  },
@@ -70,14 +105,18 @@ export const woocommerceConnector: TallyConnector = {
70
105
  sync: {
71
106
  products: wooProductSync,
72
107
  },
108
+ } satisfies Omit<TallyConnector, 'replication' | 'reconcile'>;
73
109
 
74
- replication: {
75
- products: wooProductReplication,
76
- },
77
- };
110
+ /**
111
+ * @deprecated One instance for the whole app: a store switch can leak queued reconcile work across stores.
112
+ * Use createWooCommerceConnector() per store session. Removed in 4.0.
113
+ */
114
+ export const woocommerceConnector: TallyConnector = createWooCommerceConnector();
78
115
 
79
116
  // Re-export pieces for advanced usage
117
+ export { ConnectorUnauthorizedError } from '@tallyui/core';
80
118
  export { wooProductSchema } from './schemas/products';
81
119
  export { wooProductTraits } from './traits/product';
82
120
  export { wooProductSync } from './sync/products';
83
- export { wooProductReplication } from './replication/products';
121
+ export { wooProductReplication, WooDateFilterError, WooMissingUuidError, WooTillUpdateRequiredError } from './replication/products';
122
+ export { wooCatalogueReconcile, wooReconcileFingerprint } from './reconcile/catalogue';
@@ -0,0 +1,99 @@
1
+ import { errorKind, type CatalogueReconcileAdapter, type CatalogueReconcileEntry, type ReconcileFeed, type SyncContext } from '@tallyui/core';
2
+ import { checkResponse, WooMissingUuidError } from '../replication/products';
3
+
4
+ /** The listing's page size, WordPress's per_page maximum. */
5
+ const PAGE_SIZE = 100;
6
+ /** Enough to compare: the key, the numeric id to refetch by, and the fingerprint's fields. */
7
+ const LISTING_FIELDS = 'id,uuid,date_modified_gmt,stock_quantity,stock_status';
8
+ /** The wcpos/v2/status capability that announces the bulk-id listing (wcpos/woocommerce-pos#2113). */
9
+ const ID_FAST_PATH = 'products_id_fast_path';
10
+
11
+ /**
12
+ * What the catalogue reconcile compares: the modified time and the stock (#248 A1). WooCommerce's
13
+ * `update_product_stock()` writes `_stock` by a direct query that leaves the modified time alone.
14
+ */
15
+ export const wooReconcileFingerprint = (p: { date_modified_gmt?: string | null; stock_quantity?: number | null; stock_status?: string | null }) =>
16
+ [p.date_modified_gmt ?? '', p.stock_quantity ?? '', p.stock_status ?? ''].join('|');
17
+
18
+ async function get(path: string, context: SyncContext): Promise<any> {
19
+ const response = await fetch(`${context.baseUrl}${path}`, {
20
+ headers: { ...context.headers, 'Content-Type': 'application/json' },
21
+ signal: context.signal,
22
+ });
23
+ await checkResponse(response);
24
+ return response.json();
25
+ }
26
+
27
+ /**
28
+ * The switch for the bulk-id fast path: true only when wcpos/v2/status lists `products_id_fast_path` in
29
+ * `capabilities` (a missing field is false). Never a version number, and never a failed attempt.
30
+ * A failed read (network, non-OK, non-JSON) is "no capability known", so the paged listing runs; only a
31
+ * till-class error (401, 403, 426 through checkResponse) stops the pass, since the listing would fail the same way.
32
+ */
33
+ export async function wooHasIdFastPath(context: SyncContext): Promise<boolean> {
34
+ let status: any;
35
+ try {
36
+ status = await get('/status', context);
37
+ } catch (error) {
38
+ if (errorKind(error) === 'till' || context.signal?.aborted) throw error;
39
+ return false;
40
+ }
41
+ return Array.isArray(status?.capabilities) && status.capabilities.includes(ID_FAST_PATH);
42
+ }
43
+
44
+ /**
45
+ * The fast path's one request, pinned to the contract recorded for wcpos/woocommerce-pos#2113: WCPOS's own
46
+ * `fields`, not WordPress's `_fields`, answered from SQL. The context headers carry `X-WCPOS`.
47
+ */
48
+ export function wooBulkListingUrl(baseUrl: string): string {
49
+ const params = new URLSearchParams({ per_page: '-1' });
50
+ for (const field of ['id', 'date_modified_gmt', 'stock_quantity', 'stock_status']) params.append('fields[]', field);
51
+ return `${baseUrl}/products?${params}`;
52
+ }
53
+
54
+ /**
55
+ * The daily catalogue check for WooCommerce (#248): lists every published product with its fingerprint,
56
+ * page by page with no date filter, and hands differences to `feed`, which reaches the collection only
57
+ * through the pull. `confirmGone` is the deletion proof: a by-id re-read.
58
+ */
59
+ export function wooCatalogueReconcile(feed: Pick<ReconcileFeed<any>, 'enqueue'>): CatalogueReconcileAdapter<any, number> {
60
+ return {
61
+ async *fetchPages(context, from = 1) {
62
+ // One status read per pass, yielded as an empty page so that it takes its own budget slot.
63
+ const fast = await wooHasIdFastPath(context);
64
+ yield { entries: [], cursor: from };
65
+ if (fast) {
66
+ // TODO(#2113): list with one wooBulkListingUrl request. It returns `id` but no `uuid`, and entries are
67
+ // keyed by uuid, so it needs an id-to-uuid mapping from the till's documents, which this adapter cannot
68
+ // read. Until that is designed, a store with the capability is listed page by page like any other.
69
+ }
70
+ // status=publish: a product that is not published is absent, so it becomes a deletion candidate and
71
+ // confirmGone decides; drafts cost no daily refetch.
72
+ for (let page = from; ; page++) {
73
+ const params = new URLSearchParams({
74
+ per_page: String(PAGE_SIZE), page: String(page), orderby: 'id', order: 'asc', status: 'publish', _fields: LISTING_FIELDS,
75
+ });
76
+ const rows: any[] = await get(`/products?${params}`, context);
77
+ const entries = rows.map((row): CatalogueReconcileEntry => {
78
+ if (typeof row.uuid !== 'string' || row.uuid.length === 0) throw new WooMissingUuidError(row.id);
79
+ return { key: row.uuid, fingerprint: wooReconcileFingerprint(row), remote: row.id };
80
+ });
81
+ yield { entries, cursor: page + 1 };
82
+ if (rows.length < PAGE_SIZE) return;
83
+ }
84
+ },
85
+ fingerprint: wooReconcileFingerprint,
86
+ async confirmGone(locals, context) {
87
+ const ids = locals.map((doc) => doc.id).filter((id) => Number.isInteger(id));
88
+ // An empty include= would list the whole store; a local without a numeric id has no proof and is kept.
89
+ if (!ids.length) return [];
90
+ const params = new URLSearchParams({ include: ids.join(','), per_page: String(PAGE_SIZE), status: 'any', _fields: 'id,uuid,status' });
91
+ const rows: Array<{ id: number; status: string }> = await get(`/products?${params}`, context);
92
+ const live = new Set(rows.filter((row) => row.status === 'publish').map((row) => row.id));
93
+ return locals.filter((doc) => Number.isInteger(doc.id) && !live.has(doc.id)).map((doc) => doc.uuid);
94
+ },
95
+ // An entry with no numeric id, locally or in the listing, is dropped: the store cannot be asked about it, so
96
+ // the feed would find nothing and tombstone it without proof. (The schema requires only uuid.)
97
+ enqueue: (entries) => feed.enqueue(entries.filter((e) => e.tombstone || Number.isInteger(e.local?.id) || Number.isInteger(e.remote))),
98
+ };
99
+ }
@@ -0,0 +1,34 @@
1
+ import { createReconcileFeed, type ReconcileFeed, type ReconcileFetchEntry, type SyncContext } from '@tallyui/core';
2
+ import { checkResponse, toProductDocument } from '../replication/products';
3
+
4
+ /** WordPress caps per_page at 100, so at most this many ids per include= request. */
5
+ export const MAX_IDS_PER_REQUEST = 100;
6
+
7
+ /**
8
+ * `fetchByIds` for the reconcile feed (#248): full products by numeric id (the till's own `id`, else the
9
+ * listing's `remote`), with `status=any` so that an unpublished product comes back and arrives deleted.
10
+ * The pull's rules apply through `toProductDocument`.
11
+ */
12
+ export async function wooFetchByIds(entries: Array<ReconcileFetchEntry<any>>, context: SyncContext): Promise<any[]> {
13
+ const ids = entries.map((e) => e.local?.id ?? e.remote).filter((id): id is number => Number.isInteger(id));
14
+ const documents: any[] = [];
15
+ for (let i = 0; i < ids.length; i += MAX_IDS_PER_REQUEST) {
16
+ const chunk = ids.slice(i, i + MAX_IDS_PER_REQUEST);
17
+ const params = new URLSearchParams({ include: chunk.join(','), per_page: String(MAX_IDS_PER_REQUEST), status: 'any' });
18
+ const response = await fetch(`${context.baseUrl}/products?${params}`, {
19
+ headers: { ...context.headers, 'Content-Type': 'application/json' },
20
+ signal: context.signal,
21
+ });
22
+ await checkResponse(response);
23
+ documents.push(...(await response.json()).map(toProductDocument));
24
+ }
25
+ return documents;
26
+ }
27
+
28
+ /**
29
+ * The reconcile feed for `products`, keyed by the primary key (`uuid`): the numeric `id` is only how the
30
+ * store is asked (#248 design note, §6). Its corrections reach the collection only through the pull.
31
+ */
32
+ export function createWooReconcileFeed(): ReconcileFeed<any> {
33
+ return createReconcileFeed<any>({ key: (doc) => doc.uuid, fetchByIds: wooFetchByIds });
34
+ }
@@ -1,30 +1,140 @@
1
- import type { ReplicationAdapter } from '@tallyui/core';
1
+ import { ConnectorUnauthorizedError, type ReplicationAdapter } from '@tallyui/core';
2
2
 
3
3
  export type WooProductCheckpoint = {
4
- id: string;
4
+ /** Inclusive lower bound (date_modified_gmt) of the current pass; '' = the whole catalogue. */
5
5
  modified: string;
6
+ /** Rows of the current pass already returned, in id order. */
7
+ offset: number;
8
+ /** Newest date_modified_gmt in the store when the pass started; the next pass's lower bound. */
9
+ pass_mark?: string;
10
+ /** X-WP-Total of the window on the last page; undefined when the store sends none. */
11
+ pass_count?: number;
6
12
  };
7
13
 
14
+ // RxDB drops the checkpoint of an empty result, so the handler moves on to the next useful request
15
+ // in the same call instead; every fetch in one call, mark requests included, counts against this.
16
+ const MAX_REQUESTS_PER_CALL = 4;
17
+
18
+ /** The most of a foreign 426's message kept in the error, so a store's long page never floods the log; longer is cut with "…". */
19
+ const MAX_STORE_MESSAGE = 200;
20
+
21
+ export async function checkResponse(response: Response) {
22
+ if (response.ok) return;
23
+ if (response.status === 401 || response.status === 403) {
24
+ throw new ConnectorUnauthorizedError(`WooCommerce API error: ${response.status}`, response.status);
25
+ }
26
+ if (response.status === 426) {
27
+ // Only the plugin's own gate means the till needs updating; any other 426 is retried as transient, with the store's message.
28
+ const body = await response.json().catch(() => undefined);
29
+ if (body?.code === 'wcpos_update_required') throw new WooTillUpdateRequiredError(body.code);
30
+ // `426 (code): message`, each part only when the body has it.
31
+ const code = typeof body?.code === 'string' && body.code ? ` (${body.code})` : '';
32
+ const text = typeof body?.message === 'string' ? body.message : '';
33
+ const message = text ? `: ${text.length > MAX_STORE_MESSAGE ? `${text.slice(0, MAX_STORE_MESSAGE)}…` : text}` : '';
34
+ throw new Error(`WooCommerce API error: 426${code}${message}`);
35
+ }
36
+ throw new Error(`WooCommerce API error: ${response.status}`);
37
+ }
38
+
39
+ /** WCPOS's protocol gate refused this till (HTTP 426 with `wcpos_update_required`): only updating the till fixes it. */
40
+ export class WooTillUpdateRequiredError extends Error {
41
+ name = 'WooTillUpdateRequiredError';
42
+ readonly code = 'till_update_required' as const;
43
+ /** Only updating this till fixes it, so the pull pauses until resume() (`errorKind`). */
44
+ readonly fixedBy = 'till' as const;
45
+
46
+ /** The body's `code`, kept for diagnostics. */
47
+ constructor(readonly serverCode: string | undefined) {
48
+ super('WooCommerce API error: 426: this till needs updating to sync with the store');
49
+ }
50
+ }
51
+
52
+ export class WooMissingUuidError extends Error {
53
+ name = 'WooMissingUuidError';
54
+ readonly code = 'missing_plugin' as const;
55
+ /** Only the store owner can fix it, by installing or enabling the plugin, so the pull waits the store delay (`errorKind`). */
56
+ readonly fixedBy = 'store' as const;
57
+ productId: number;
58
+
59
+ constructor(id: number) {
60
+ super(`WooCommerce product ${id} has no uuid: the store must run the WCPOS Free plugin (1.10.0 or later) and be reached through its wcpos/v2 routes`);
61
+ this.productId = id;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * One product row as the till stores it, the rule the pull and the reconcile feed share: a uuid is
67
+ * required (WooMissingUuidError), and a product that is not published arrives deleted (#229).
68
+ */
69
+ export function toProductDocument(product: any): any {
70
+ if (typeof product.uuid !== 'string' || product.uuid.length === 0) throw new WooMissingUuidError(product.id);
71
+ return { ...product, _deleted: product.status !== 'publish' };
72
+ }
73
+
74
+ /** The store returned a product outside a modified_after window, so it does not apply the filter. */
75
+ export class WooDateFilterError extends Error {
76
+ name = 'WooDateFilterError';
77
+ readonly code = 'unsupported_store' as const;
78
+ /** Only the store owner can fix it, by updating WooCommerce, so the pull waits the store delay (`errorKind`). */
79
+ readonly fixedBy = 'store' as const;
80
+ /** The software to update, for `SyncStatus`'s detail; the component never names it itself. */
81
+ readonly software = 'WooCommerce';
82
+ /** The first WooCommerce version that applies `modified_after` (5.8). */
83
+ readonly minVersion = '5.8';
84
+
85
+ constructor(readonly productId: number | undefined, readonly bound: string, readonly received: string | undefined) {
86
+ super('This store needs WooCommerce 5.8 or later to sync products.');
87
+ }
88
+ }
89
+
8
90
  /**
9
91
  * Replication adapter for WooCommerce products.
10
92
  *
11
- * Pull-only (cursor-based pagination by date_modified_gmt) for RxDB's
93
+ * Pull-only (id-ordered offset pages within a date_modified_gmt window) for RxDB's
12
94
  * replicateRxCollection. Catalogue data is server-owned; the POS never
13
95
  * writes products.
14
96
  */
15
97
  export const wooProductReplication: ReplicationAdapter<any, WooProductCheckpoint> = {
16
98
  pull: {
17
- async handler(lastCheckpoint, batchSize, context) {
99
+ handler: async function pull(lastCheckpoint, batchSize, context, requests = 0): Promise<{ documents: any[]; checkpoint: WooProductCheckpoint }> {
100
+ const modified = lastCheckpoint?.modified ?? '';
101
+ const offset = lastCheckpoint?.offset ?? 0;
102
+ // Budget spent: a call never fetches after gathering documents, so return [] and let the stored checkpoint stand.
103
+ const spent = { documents: [], checkpoint: lastCheckpoint ?? { modified, offset } };
104
+ let passMark = lastCheckpoint?.pass_mark ?? modified;
105
+ if (offset === 0 && lastCheckpoint?.pass_mark === undefined) {
106
+ if (requests++ >= MAX_REQUESTS_PER_CALL) return spent;
107
+ // orderby=modified sorts by local time, so the store is asked in GMT whether anything is newer than L.
108
+ // No Z or offset on modified_after: WP_Date_Query::build_mysql_datetime parses it in the site time zone and
109
+ // formats it back in that zone, so an offset would move the bound to local time before the post_modified_gmt comparison.
110
+ const newer: Record<string, string> = modified ? { modified_after: modified, dates_are_gmt: 'true' } : {};
111
+ const mark = new URLSearchParams({ per_page: '1', orderby: 'modified', order: 'desc', ...newer });
112
+ const markResponse = await fetch(
113
+ `${context.baseUrl}/products?${mark}`,
114
+ { headers: { ...context.headers, 'Content-Type': 'application/json' }, signal: context.signal },
115
+ );
116
+ await checkResponse(markResponse);
117
+ const [newest] = await markResponse.json();
118
+ // Nothing modified after L (or no product at all): every window is empty.
119
+ if (newest === undefined) return { documents: [], checkpoint: lastCheckpoint ?? { modified: '', offset: 0 } };
120
+ if (modified && !(newest.date_modified_gmt > modified)) throw new WooDateFilterError(newest.id, modified, newest.date_modified_gmt);
121
+ // The local sort may put an older GMT time first: as the next lower bound that costs a re-read, never a skip.
122
+ passMark = newest.date_modified_gmt ?? modified;
123
+ }
18
124
  const params = new URLSearchParams({
19
125
  per_page: String(batchSize),
20
- orderby: 'modified',
126
+ offset: String(offset),
127
+ orderby: 'id',
21
128
  order: 'asc',
22
129
  });
23
130
 
24
- if (lastCheckpoint?.modified) {
25
- params.set('modified_after', lastCheckpoint.modified);
131
+ if (modified) {
132
+ // GMT digits with no offset, for WP_Date_Query (see the mark request).
133
+ params.set('modified_after', new Date(Date.parse(modified + 'Z') - 1000).toISOString().slice(0, 19));
134
+ params.set('dates_are_gmt', 'true');
26
135
  }
27
136
 
137
+ if (requests++ >= MAX_REQUESTS_PER_CALL) return spent;
28
138
  const response = await fetch(`${context.baseUrl}/products?${params}`, {
29
139
  headers: {
30
140
  ...context.headers,
@@ -33,19 +143,36 @@ export const wooProductReplication: ReplicationAdapter<any, WooProductCheckpoint
33
143
  signal: context.signal,
34
144
  });
35
145
 
36
- if (!response.ok) {
37
- throw new Error(`WooCommerce API error: ${response.status}`);
38
- }
146
+ await checkResponse(response);
39
147
 
40
148
  const products: any[] = await response.json();
41
- const documents = products.map((p) => ({ ...p, _deleted: false }));
42
-
43
- const checkpoint: WooProductCheckpoint = products.length > 0
44
- ? {
45
- id: String(products[products.length - 1].uuid ?? products[products.length - 1].id),
46
- modified: products[products.length - 1].date_modified_gmt,
47
- }
48
- : lastCheckpoint ?? { id: '', modified: '' };
149
+ const total = response.headers.get('X-WP-Total');
150
+ const count = total !== null && /^\d+$/.test(total) ? Number(total) : undefined;
151
+ if (offset > 0 && count !== undefined && (products.length === 0 || count < (lastCheckpoint?.pass_count ?? count))) {
152
+ // Restart the pass in this call, so RxDB stores its first page's checkpoint; the request budget bounds the call.
153
+ return pull({ modified, offset: 0, pass_mark: passMark }, batchSize, context, requests);
154
+ }
155
+ const documents = products.map((product) => {
156
+ const document = toProductDocument(product);
157
+ // The window is modified_after = L − 1 s, so a product missing its time or below L was not filtered.
158
+ if (modified && !(product.date_modified_gmt >= modified)) {
159
+ throw new WooDateFilterError(product.id, params.get('modified_after')!, product.date_modified_gmt);
160
+ }
161
+ return document;
162
+ });
163
+
164
+ // RxDB merges checkpoints, so clear pass state explicitly at completion.
165
+ const complete = count === undefined ? products.length < batchSize : offset + products.length >= count;
166
+ const checkpoint: WooProductCheckpoint = complete
167
+ ? { modified: passMark, offset: 0, pass_mark: undefined, pass_count: undefined }
168
+ : { modified, offset: offset + products.length, pass_mark: passMark, pass_count: count };
169
+ if (complete && products.length === 0) {
170
+ // Chain into the next pass: RxDB would drop this completion with the empty result. While the next pass
171
+ // has nothing either, each poll re-derives these requests from the stored checkpoint, at most
172
+ // MAX_REQUESTS_PER_CALL of them.
173
+ const next = await pull({ modified: passMark, offset: 0 }, batchSize, context, requests);
174
+ return next.documents.length > 0 ? next : { documents: [], checkpoint: lastCheckpoint ?? checkpoint };
175
+ }
49
176
 
50
177
  return { documents, checkpoint };
51
178
  },
@@ -50,8 +50,13 @@ export const wooProductSync: CollectionSync = {
50
50
  },
51
51
 
52
52
  async fetchModifiedAfter(date: string, context: SyncContext): Promise<any[]> {
53
+ const params = new URLSearchParams({
54
+ modified_after: date,
55
+ dates_are_gmt: 'true',
56
+ per_page: '100',
57
+ });
53
58
  const response = await fetch(
54
- `${context.baseUrl}/products?modified_after=${date}&per_page=100`,
59
+ `${context.baseUrl}/products?${params}`,
55
60
  {
56
61
  headers: {
57
62
  ...context.headers,