@tallyui/connector-woocommerce 3.0.0-next.0 → 3.0.0-next.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/README.md CHANGED
@@ -30,11 +30,12 @@ The incremental pull can miss an edit: a `modified_after` that over-excludes (in
30
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
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
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.
33
+ - When `wcpos/v2/status` lists `products_id_fast_path` in `capabilities`, the whole catalogue is listed in one request (`per_page=-1` with `_fields=id,date_modified_gmt,stock_quantity,stock_status`) instead of pages of 100; a store that refuses it (a 400) is listed page by page in the same pass.
34
+ - Both listings key on the numeric product id, never the till-local uuid; the adapter's `matchKey` is how the runner finds the till's copy.
34
35
 
35
36
  ## Errors
36
37
 
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
+ - `ConnectorUnauthorizedError`, 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. `error.status` is `401` (`code: 'unauthorized'`, sign in again) or `403` (`code: 'forbidden'`, signed in but not allowed: the pull retries on the store schedule; don't sign out).
38
39
  - `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
40
  - `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
41
  - `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
@@ -88,9 +88,9 @@ declare const wooReconcileFingerprint: (p: {
88
88
  stock_status?: string | null;
89
89
  }) => string;
90
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.
91
+ * The daily catalogue check for WooCommerce (#248): lists every published product with its fingerprint, in one
92
+ * fast-path request where the store offers it (#313), else page by page, with no date filter, and hands differences
93
+ * to `feed`, which reaches the collection only through the pull. `confirmGone` is the deletion proof: a by-id re-read.
94
94
  */
95
95
  declare function wooCatalogueReconcile(feed: Pick<ReconcileFeed<any>, 'enqueue'>): CatalogueReconcileAdapter<any, number>;
96
96
 
package/dist/index.js CHANGED
@@ -380,7 +380,7 @@ var wooProductReplication = {
380
380
  // src/reconcile/catalogue.ts
381
381
  import { errorKind } from "@tallyui/core";
382
382
  var PAGE_SIZE = 100;
383
- var LISTING_FIELDS = "id,uuid,date_modified_gmt,stock_quantity,stock_status";
383
+ var LISTING_FIELDS = "id,date_modified_gmt,stock_quantity,stock_status";
384
384
  var ID_FAST_PATH = "products_id_fast_path";
385
385
  var wooReconcileFingerprint = (p) => [p.date_modified_gmt ?? "", p.stock_quantity ?? "", p.stock_status ?? ""].join("|");
386
386
  async function get(path, context) {
@@ -401,12 +401,30 @@ async function wooHasIdFastPath(context) {
401
401
  }
402
402
  return Array.isArray(status?.capabilities) && status.capabilities.includes(ID_FAST_PATH);
403
403
  }
404
+ function wooBulkListingUrl(baseUrl) {
405
+ return `${baseUrl}/products?${new URLSearchParams({ per_page: "-1", _fields: LISTING_FIELDS })}`;
406
+ }
407
+ var toEntries = (rows) => rows.filter((row) => Number.isInteger(row?.id)).map((row) => ({ key: String(row.id), fingerprint: wooReconcileFingerprint(row), remote: row.id }));
404
408
  function wooCatalogueReconcile(feed) {
405
409
  return {
406
410
  async *fetchPages(context, from = 1) {
407
411
  const fast = await wooHasIdFastPath(context);
408
412
  yield { entries: [], cursor: from };
409
413
  if (fast) {
414
+ let rows;
415
+ let failed = false;
416
+ try {
417
+ rows = await get(wooBulkListingUrl(""), context);
418
+ } catch (error) {
419
+ if (errorKind(error) === "till" || context.signal?.aborted) throw error;
420
+ failed = true;
421
+ console.warn(`WooCommerce catalogue reconcile: the fast path failed (${error?.message ?? error}); listing page by page.`);
422
+ }
423
+ if (Array.isArray(rows)) {
424
+ yield { entries: toEntries(rows), cursor: from };
425
+ return;
426
+ }
427
+ if (!failed) console.warn("WooCommerce catalogue reconcile: the fast path answered with something that is not a list; listing page by page.");
410
428
  }
411
429
  for (let page = from; ; page++) {
412
430
  const params = new URLSearchParams({
@@ -418,15 +436,13 @@ function wooCatalogueReconcile(feed) {
418
436
  _fields: LISTING_FIELDS
419
437
  });
420
438
  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 };
439
+ yield { entries: toEntries(rows), cursor: page + 1 };
426
440
  if (rows.length < PAGE_SIZE) return;
427
441
  }
428
442
  },
429
443
  fingerprint: wooReconcileFingerprint,
444
+ // Both listings key on the numeric id, never the till-local uuid (#313).
445
+ matchKey: (doc) => Number.isInteger(doc.id) ? String(doc.id) : void 0,
430
446
  async confirmGone(locals, context) {
431
447
  const ids = locals.map((doc) => doc.id).filter((id) => Number.isInteger(id));
432
448
  if (!ids.length) return [];
@@ -460,11 +476,11 @@ async function wooFetchByIds(entries, context) {
460
476
  return documents;
461
477
  }
462
478
  function createWooReconcileFeed() {
463
- return createReconcileFeed({ key: (doc) => doc.uuid, fetchByIds: wooFetchByIds });
479
+ return createReconcileFeed({ key: (doc) => doc.uuid, remote: (doc) => doc.id, fetchByIds: wooFetchByIds });
464
480
  }
465
481
 
466
482
  // package.json
467
- var version = "3.0.0-next.0";
483
+ var version = "3.0.0-next.1";
468
484
 
469
485
  // src/index.ts
470
486
  import { ConnectorUnauthorizedError as ConnectorUnauthorizedError3 } from "@tallyui/core";
@@ -473,7 +489,7 @@ function wcposClientPart(part) {
473
489
  }
474
490
  var WooMissingTokenError = class extends ConnectorUnauthorizedError2 {
475
491
  constructor() {
476
- super("WooCommerce credentials have no WCPOS access token: sign in again");
492
+ super("WooCommerce credentials have no WCPOS access token: sign in again", 401);
477
493
  this.name = "WooMissingTokenError";
478
494
  }
479
495
  };
@@ -482,9 +498,10 @@ function createWooCommerceConnector() {
482
498
  return {
483
499
  ...wooConnectorParts,
484
500
  replication: {
485
- // One replication per collection; the reconcile feed is last, so its fetch wins duplicates. legacyKey
501
+ // One replication per collection; the reconcile feed is last, so its fetch wins duplicates;
502
+ // duplicates are matched by uuid, the primary key, not the store id (#331). legacyKey
486
503
  // 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" })
504
+ products: combinePullAdapters({ products: wooProductReplication, reconcile: catalogueFeed.adapter }, { legacyKey: "products", key: (doc) => doc.uuid })
488
505
  },
489
506
  reconcile: {
490
507
  // The feed's fetchByIds asks for at most this many ids per request; the runner budgets by requests.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tallyui/connector-woocommerce",
3
- "version": "3.0.0-next.0",
3
+ "version": "3.0.0-next.1",
4
4
  "type": "module",
5
5
  "description": "WooCommerce connector for Tally UI",
6
6
  "main": "./dist/index.js",
@@ -26,10 +26,10 @@
26
26
  "directory": "connectors/woocommerce"
27
27
  },
28
28
  "peerDependencies": {
29
- "@tallyui/core": "^3.0.0-next.0"
29
+ "@tallyui/core": "^3.0.0-next.1"
30
30
  },
31
31
  "devDependencies": {
32
- "@tallyui/database": "3.0.0-next.0",
32
+ "@tallyui/database": "3.0.0-next.1",
33
33
  "rxdb": "17.5.0"
34
34
  },
35
35
  "scripts": {
package/src/index.ts CHANGED
@@ -15,7 +15,7 @@ export function wcposClientPart(part: string): string {
15
15
 
16
16
  export class WooMissingTokenError extends ConnectorUnauthorizedError {
17
17
  constructor() {
18
- super('WooCommerce credentials have no WCPOS access token: sign in again');
18
+ super('WooCommerce credentials have no WCPOS access token: sign in again', 401);
19
19
  this.name = 'WooMissingTokenError';
20
20
  }
21
21
  }
@@ -44,9 +44,10 @@ export function createWooCommerceConnector(): TallyConnector {
44
44
  return {
45
45
  ...wooConnectorParts,
46
46
  replication: {
47
- // One replication per collection; the reconcile feed is last, so its fetch wins duplicates. legacyKey
47
+ // One replication per collection; the reconcile feed is last, so its fetch wins duplicates;
48
+ // duplicates are matched by uuid, the primary key, not the store id (#331). legacyKey
48
49
  // 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
+ products: combinePullAdapters({ products: wooProductReplication, reconcile: catalogueFeed.adapter }, { legacyKey: 'products', key: (doc: any) => doc.uuid }),
50
51
  },
51
52
  reconcile: {
52
53
  // The feed's fetchByIds asks for at most this many ids per request; the runner budgets by requests.
@@ -1,10 +1,13 @@
1
1
  import { errorKind, type CatalogueReconcileAdapter, type CatalogueReconcileEntry, type ReconcileFeed, type SyncContext } from '@tallyui/core';
2
- import { checkResponse, WooMissingUuidError } from '../replication/products';
2
+ import { checkResponse } from '../replication/products';
3
3
 
4
4
  /** The listing's page size, WordPress's per_page maximum. */
5
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';
6
+ /**
7
+ * Both listings' fields, and exactly the fast path's: the numeric id (the key, and how a product is refetched) and
8
+ * the fingerprint's. No uuid: the pull's `toProductDocument` still requires one on every product it delivers.
9
+ */
10
+ const LISTING_FIELDS = 'id,date_modified_gmt,stock_quantity,stock_status';
8
11
  /** The wcpos/v2/status capability that announces the bulk-id listing (wcpos/woocommerce-pos#2113). */
9
12
  const ID_FAST_PATH = 'products_id_fast_path';
10
13
 
@@ -42,19 +45,21 @@ export async function wooHasIdFastPath(context: SyncContext): Promise<boolean> {
42
45
  }
43
46
 
44
47
  /**
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`.
48
+ * The fast path's one request (wcpos/woocommerce-pos #2116, #2119, #2121): WordPress's `_fields` with exactly these
49
+ * four, answered from SQL, published POS-visible products only. The context headers carry `X-WCPOS`.
47
50
  */
48
51
  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
+ return `${baseUrl}/products?${new URLSearchParams({ per_page: '-1', _fields: LISTING_FIELDS })}`;
52
53
  }
53
54
 
55
+ /** Listing rows as entries keyed by the numeric id (#313); a row without an integer id cannot be asked about, and is dropped. */
56
+ const toEntries = (rows: any[]) => rows.filter((row) => Number.isInteger(row?.id))
57
+ .map((row): CatalogueReconcileEntry => ({ key: String(row.id), fingerprint: wooReconcileFingerprint(row), remote: row.id }));
58
+
54
59
  /**
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.
60
+ * The daily catalogue check for WooCommerce (#248): lists every published product with its fingerprint, in one
61
+ * fast-path request where the store offers it (#313), else page by page, with no date filter, and hands differences
62
+ * to `feed`, which reaches the collection only through the pull. `confirmGone` is the deletion proof: a by-id re-read.
58
63
  */
59
64
  export function wooCatalogueReconcile(feed: Pick<ReconcileFeed<any>, 'enqueue'>): CatalogueReconcileAdapter<any, number> {
60
65
  return {
@@ -63,9 +68,22 @@ export function wooCatalogueReconcile(feed: Pick<ReconcileFeed<any>, 'enqueue'>)
63
68
  const fast = await wooHasIdFastPath(context);
64
69
  yield { entries: [], cursor: from };
65
70
  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.
71
+ // The whole catalogue in one request, so `from` is moot. A store that refuses it (a store scope answers 400)
72
+ // is listed page by page in this pass; only a till-class error or an abort stops it.
73
+ let rows: any;
74
+ let failed = false;
75
+ try {
76
+ rows = await get(wooBulkListingUrl(''), context); // get() prefixes context.baseUrl
77
+ } catch (error) {
78
+ if (errorKind(error) === 'till' || context.signal?.aborted) throw error;
79
+ failed = true;
80
+ console.warn(`WooCommerce catalogue reconcile: the fast path failed (${(error as Error)?.message ?? error}); listing page by page.`);
81
+ }
82
+ if (Array.isArray(rows)) {
83
+ yield { entries: toEntries(rows), cursor: from };
84
+ return;
85
+ }
86
+ if (!failed) console.warn('WooCommerce catalogue reconcile: the fast path answered with something that is not a list; listing page by page.');
69
87
  }
70
88
  // status=publish: a product that is not published is absent, so it becomes a deletion candidate and
71
89
  // confirmGone decides; drafts cost no daily refetch.
@@ -74,15 +92,13 @@ export function wooCatalogueReconcile(feed: Pick<ReconcileFeed<any>, 'enqueue'>)
74
92
  per_page: String(PAGE_SIZE), page: String(page), orderby: 'id', order: 'asc', status: 'publish', _fields: LISTING_FIELDS,
75
93
  });
76
94
  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 };
95
+ yield { entries: toEntries(rows), cursor: page + 1 };
82
96
  if (rows.length < PAGE_SIZE) return;
83
97
  }
84
98
  },
85
99
  fingerprint: wooReconcileFingerprint,
100
+ // Both listings key on the numeric id, never the till-local uuid (#313).
101
+ matchKey: (doc) => (Number.isInteger(doc.id) ? String(doc.id) : undefined),
86
102
  async confirmGone(locals, context) {
87
103
  const ids = locals.map((doc) => doc.id).filter((id) => Number.isInteger(id));
88
104
  // An empty include= would list the whole store; a local without a numeric id has no proof and is kept.
@@ -26,9 +26,10 @@ export async function wooFetchByIds(entries: Array<ReconcileFetchEntry<any>>, co
26
26
  }
27
27
 
28
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.
29
+ * The reconcile feed for `products`, keyed by the primary key (`uuid`): the numeric `id` is how the store is
30
+ * asked (#248 design note, §6), and how a listed product the till lacks, queued under its id, is matched (#313).
31
+ * Its corrections reach the collection only through the pull.
31
32
  */
32
33
  export function createWooReconcileFeed(): ReconcileFeed<any> {
33
- return createReconcileFeed<any>({ key: (doc) => doc.uuid, fetchByIds: wooFetchByIds });
34
+ return createReconcileFeed<any>({ key: (doc) => doc.uuid, remote: (doc) => doc.id, fetchByIds: wooFetchByIds });
34
35
  }
@@ -21,7 +21,7 @@ const MAX_STORE_MESSAGE = 200;
21
21
  export async function checkResponse(response: Response) {
22
22
  if (response.ok) return;
23
23
  if (response.status === 401 || response.status === 403) {
24
- throw new ConnectorUnauthorizedError(`WooCommerce API error: ${response.status}`, response.status);
24
+ throw new ConnectorUnauthorizedError(`WooCommerce API error: ${response.status}`, response.status as 401 | 403);
25
25
  }
26
26
  if (response.status === 426) {
27
27
  // Only the plugin's own gate means the till needs updating; any other 426 is retried as transient, with the store's message.