@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 +3 -2
- package/dist/index.d.ts +3 -3
- package/dist/index.js +28 -11
- package/package.json +3 -3
- package/src/index.ts +4 -3
- package/src/reconcile/catalogue.ts +35 -19
- package/src/reconcile/feed.ts +4 -3
- package/src/replication/products.ts +1 -1
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
|
-
-
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
29
|
+
"@tallyui/core": "^3.0.0-next.1"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
|
-
"@tallyui/database": "3.0.0-next.
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
7
|
-
|
|
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
|
|
46
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
67
|
-
//
|
|
68
|
-
|
|
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
|
-
|
|
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.
|
package/src/reconcile/feed.ts
CHANGED
|
@@ -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
|
|
30
|
-
*
|
|
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.
|