@tallyui/connector-woocommerce 3.0.0-next.0 → 3.0.0-next.2
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 +4 -2
- package/dist/index.d.ts +14 -4
- package/dist/index.js +42 -11
- package/package.json +3 -3
- package/src/index.ts +5 -4
- package/src/reconcile/catalogue.ts +39 -21
- package/src/reconcile/feed.ts +4 -3
- package/src/replication/products.ts +17 -1
package/README.md
CHANGED
|
@@ -30,11 +30,13 @@ 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 refused the request (401: the token is missing, expired or revoked; 403: signed in but not allowed), 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).
|
|
39
|
+
- `WooPluginUpdateRequiredError` (`code: 'unsupported_store'`, `fixedBy: 'store'`): the store answered a `jwt_auth_*` 403, which WCPOS before 1.10.8 sends for a valid token (wcpos#1863). The store owner must update WCPOS to `minVersion` (`1.10.8`); the pull retries on the store schedule and the till stays signed in.
|
|
38
40
|
- `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
41
|
- `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
42
|
- `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
|
@@ -69,6 +69,16 @@ declare class WooDateFilterError extends Error {
|
|
|
69
69
|
readonly minVersion = "5.8";
|
|
70
70
|
constructor(productId: number | undefined, bound: string, received: string | undefined);
|
|
71
71
|
}
|
|
72
|
+
/** A stale JWT plugin 403 on WCPOS 1.10.0–1.10.7 (wcpos/woocommerce-pos#1863; fixed by 7243675c9). */
|
|
73
|
+
declare class WooPluginUpdateRequiredError extends Error {
|
|
74
|
+
readonly storeCode: string;
|
|
75
|
+
name: string;
|
|
76
|
+
readonly code: "unsupported_store";
|
|
77
|
+
readonly fixedBy: "store";
|
|
78
|
+
readonly software = "WCPOS";
|
|
79
|
+
readonly minVersion = "1.10.8";
|
|
80
|
+
constructor(storeCode: string);
|
|
81
|
+
}
|
|
72
82
|
/**
|
|
73
83
|
* Replication adapter for WooCommerce products.
|
|
74
84
|
*
|
|
@@ -88,9 +98,9 @@ declare const wooReconcileFingerprint: (p: {
|
|
|
88
98
|
stock_status?: string | null;
|
|
89
99
|
}) => string;
|
|
90
100
|
/**
|
|
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.
|
|
101
|
+
* The daily catalogue check for WooCommerce (#248): lists every published product with its fingerprint, in one
|
|
102
|
+
* fast-path request where the store offers it (#313), else page by page, with no date filter, and hands differences
|
|
103
|
+
* to `feed`, which reaches the collection only through the pull. `confirmGone` is the deletion proof: a by-id re-read.
|
|
94
104
|
*/
|
|
95
105
|
declare function wooCatalogueReconcile(feed: Pick<ReconcileFeed<any>, 'enqueue'>): CatalogueReconcileAdapter<any, number>;
|
|
96
106
|
|
|
@@ -124,4 +134,4 @@ declare function createWooCommerceConnector(): TallyConnector;
|
|
|
124
134
|
*/
|
|
125
135
|
declare const woocommerceConnector: TallyConnector;
|
|
126
136
|
|
|
127
|
-
export { WooDateFilterError, WooMissingTokenError, WooMissingUuidError, WooTillUpdateRequiredError, createWooCommerceConnector, wcposClientPart, wooCatalogueReconcile, wooProductReplication, wooProductSchema, wooProductSync, wooProductTraits, wooReconcileFingerprint, woocommerceConnector };
|
|
137
|
+
export { WooDateFilterError, WooMissingTokenError, WooMissingUuidError, WooPluginUpdateRequiredError, WooTillUpdateRequiredError, createWooCommerceConnector, wcposClientPart, wooCatalogueReconcile, wooProductReplication, wooProductSchema, wooProductSync, wooProductTraits, wooReconcileFingerprint, woocommerceConnector };
|
package/dist/index.js
CHANGED
|
@@ -259,6 +259,8 @@ var MAX_STORE_MESSAGE = 200;
|
|
|
259
259
|
async function checkResponse(response) {
|
|
260
260
|
if (response.ok) return;
|
|
261
261
|
if (response.status === 401 || response.status === 403) {
|
|
262
|
+
const body = response.status === 403 ? await response.json().catch(() => void 0) : void 0;
|
|
263
|
+
if (typeof body?.code === "string" && body.code.startsWith("jwt_auth_")) throw new WooPluginUpdateRequiredError(body.code);
|
|
262
264
|
throw new ConnectorUnauthorizedError(`WooCommerce API error: ${response.status}`, response.status);
|
|
263
265
|
}
|
|
264
266
|
if (response.status === 426) {
|
|
@@ -313,6 +315,17 @@ var WooDateFilterError = class extends Error {
|
|
|
313
315
|
/** The first WooCommerce version that applies `modified_after` (5.8). */
|
|
314
316
|
minVersion = "5.8";
|
|
315
317
|
};
|
|
318
|
+
var WooPluginUpdateRequiredError = class extends Error {
|
|
319
|
+
constructor(storeCode) {
|
|
320
|
+
super("This store needs WCPOS 1.10.8 or later: the JWT Authentication plugin blocks the till on older versions.");
|
|
321
|
+
this.storeCode = storeCode;
|
|
322
|
+
}
|
|
323
|
+
name = "WooPluginUpdateRequiredError";
|
|
324
|
+
code = "unsupported_store";
|
|
325
|
+
fixedBy = "store";
|
|
326
|
+
software = "WCPOS";
|
|
327
|
+
minVersion = "1.10.8";
|
|
328
|
+
};
|
|
316
329
|
var wooProductReplication = {
|
|
317
330
|
pull: {
|
|
318
331
|
handler: async function pull(lastCheckpoint, batchSize, context, requests = 0) {
|
|
@@ -380,7 +393,7 @@ var wooProductReplication = {
|
|
|
380
393
|
// src/reconcile/catalogue.ts
|
|
381
394
|
import { errorKind } from "@tallyui/core";
|
|
382
395
|
var PAGE_SIZE = 100;
|
|
383
|
-
var LISTING_FIELDS = "id,
|
|
396
|
+
var LISTING_FIELDS = "id,date_modified_gmt,stock_quantity,stock_status";
|
|
384
397
|
var ID_FAST_PATH = "products_id_fast_path";
|
|
385
398
|
var wooReconcileFingerprint = (p) => [p.date_modified_gmt ?? "", p.stock_quantity ?? "", p.stock_status ?? ""].join("|");
|
|
386
399
|
async function get(path, context) {
|
|
@@ -401,12 +414,30 @@ async function wooHasIdFastPath(context) {
|
|
|
401
414
|
}
|
|
402
415
|
return Array.isArray(status?.capabilities) && status.capabilities.includes(ID_FAST_PATH);
|
|
403
416
|
}
|
|
417
|
+
function wooBulkListingUrl(baseUrl) {
|
|
418
|
+
return `${baseUrl}/products?${new URLSearchParams({ per_page: "-1", _fields: LISTING_FIELDS })}`;
|
|
419
|
+
}
|
|
420
|
+
var toEntries = (rows) => rows.filter((row) => Number.isInteger(row?.id)).map((row) => ({ key: String(row.id), fingerprint: wooReconcileFingerprint(row), remote: row.id }));
|
|
404
421
|
function wooCatalogueReconcile(feed) {
|
|
405
422
|
return {
|
|
406
423
|
async *fetchPages(context, from = 1) {
|
|
407
424
|
const fast = await wooHasIdFastPath(context);
|
|
408
425
|
yield { entries: [], cursor: from };
|
|
409
426
|
if (fast) {
|
|
427
|
+
let rows;
|
|
428
|
+
let failed = false;
|
|
429
|
+
try {
|
|
430
|
+
rows = await get(wooBulkListingUrl(""), context);
|
|
431
|
+
} catch (error) {
|
|
432
|
+
if (errorKind(error) === "till" || context.signal?.aborted) throw error;
|
|
433
|
+
failed = true;
|
|
434
|
+
console.warn(`WooCommerce catalogue reconcile: the fast path failed (${error?.message ?? error}); listing page by page.`);
|
|
435
|
+
}
|
|
436
|
+
if (Array.isArray(rows)) {
|
|
437
|
+
yield { entries: toEntries(rows), cursor: from };
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
if (!failed) console.warn("WooCommerce catalogue reconcile: the fast path answered with something that is not a list; listing page by page.");
|
|
410
441
|
}
|
|
411
442
|
for (let page = from; ; page++) {
|
|
412
443
|
const params = new URLSearchParams({
|
|
@@ -418,15 +449,13 @@ function wooCatalogueReconcile(feed) {
|
|
|
418
449
|
_fields: LISTING_FIELDS
|
|
419
450
|
});
|
|
420
451
|
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 };
|
|
452
|
+
yield { entries: toEntries(rows), cursor: page + 1 };
|
|
426
453
|
if (rows.length < PAGE_SIZE) return;
|
|
427
454
|
}
|
|
428
455
|
},
|
|
429
456
|
fingerprint: wooReconcileFingerprint,
|
|
457
|
+
// Both listings key on the numeric id, never the till-local uuid (#313).
|
|
458
|
+
matchKey: (doc) => Number.isInteger(doc.id) ? String(doc.id) : void 0,
|
|
430
459
|
async confirmGone(locals, context) {
|
|
431
460
|
const ids = locals.map((doc) => doc.id).filter((id) => Number.isInteger(id));
|
|
432
461
|
if (!ids.length) return [];
|
|
@@ -460,11 +489,11 @@ async function wooFetchByIds(entries, context) {
|
|
|
460
489
|
return documents;
|
|
461
490
|
}
|
|
462
491
|
function createWooReconcileFeed() {
|
|
463
|
-
return createReconcileFeed({ key: (doc) => doc.uuid, fetchByIds: wooFetchByIds });
|
|
492
|
+
return createReconcileFeed({ key: (doc) => doc.uuid, remote: (doc) => doc.id, fetchByIds: wooFetchByIds });
|
|
464
493
|
}
|
|
465
494
|
|
|
466
495
|
// package.json
|
|
467
|
-
var version = "3.0.0-next.
|
|
496
|
+
var version = "3.0.0-next.2";
|
|
468
497
|
|
|
469
498
|
// src/index.ts
|
|
470
499
|
import { ConnectorUnauthorizedError as ConnectorUnauthorizedError3 } from "@tallyui/core";
|
|
@@ -473,7 +502,7 @@ function wcposClientPart(part) {
|
|
|
473
502
|
}
|
|
474
503
|
var WooMissingTokenError = class extends ConnectorUnauthorizedError2 {
|
|
475
504
|
constructor() {
|
|
476
|
-
super("WooCommerce credentials have no WCPOS access token: sign in again");
|
|
505
|
+
super("WooCommerce credentials have no WCPOS access token: sign in again", 401);
|
|
477
506
|
this.name = "WooMissingTokenError";
|
|
478
507
|
}
|
|
479
508
|
};
|
|
@@ -482,9 +511,10 @@ function createWooCommerceConnector() {
|
|
|
482
511
|
return {
|
|
483
512
|
...wooConnectorParts,
|
|
484
513
|
replication: {
|
|
485
|
-
// One replication per collection; the reconcile feed is last, so its fetch wins duplicates
|
|
514
|
+
// One replication per collection; the reconcile feed is last, so its fetch wins duplicates;
|
|
515
|
+
// duplicates are matched by uuid, the primary key, not the store id (#331). legacyKey
|
|
486
516
|
// 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" })
|
|
517
|
+
products: combinePullAdapters({ products: wooProductReplication, reconcile: catalogueFeed.adapter }, { legacyKey: "products", key: (doc) => doc.uuid })
|
|
488
518
|
},
|
|
489
519
|
reconcile: {
|
|
490
520
|
// The feed's fetchByIds asks for at most this many ids per request; the runner budgets by requests.
|
|
@@ -545,6 +575,7 @@ export {
|
|
|
545
575
|
WooDateFilterError,
|
|
546
576
|
WooMissingTokenError,
|
|
547
577
|
WooMissingUuidError,
|
|
578
|
+
WooPluginUpdateRequiredError,
|
|
548
579
|
WooTillUpdateRequiredError,
|
|
549
580
|
createWooCommerceConnector,
|
|
550
581
|
wcposClientPart,
|
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.2",
|
|
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.2"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
|
-
"@tallyui/database": "3.0.0-next.
|
|
32
|
+
"@tallyui/database": "3.0.0-next.2",
|
|
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.
|
|
@@ -118,5 +119,5 @@ export { ConnectorUnauthorizedError } from '@tallyui/core';
|
|
|
118
119
|
export { wooProductSchema } from './schemas/products';
|
|
119
120
|
export { wooProductTraits } from './traits/product';
|
|
120
121
|
export { wooProductSync } from './sync/products';
|
|
121
|
-
export { wooProductReplication, WooDateFilterError, WooMissingUuidError, WooTillUpdateRequiredError } from './replication/products';
|
|
122
|
+
export { wooProductReplication, WooDateFilterError, WooPluginUpdateRequiredError, WooMissingUuidError, WooTillUpdateRequiredError } from './replication/products';
|
|
122
123
|
export { wooCatalogueReconcile, wooReconcileFingerprint } from './reconcile/catalogue';
|
|
@@ -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
|
|
|
@@ -27,8 +30,9 @@ async function get(path: string, context: SyncContext): Promise<any> {
|
|
|
27
30
|
/**
|
|
28
31
|
* The switch for the bulk-id fast path: true only when wcpos/v2/status lists `products_id_fast_path` in
|
|
29
32
|
* `capabilities` (a missing field is false). Never a version number, and never a failed attempt.
|
|
30
|
-
* A failed read
|
|
31
|
-
* till-class error (401
|
|
33
|
+
* A failed status read is "no capability known"; only a 401 or 403 would make the paged listing fail the same way.
|
|
34
|
+
* Only a till-class error (401 `unauthorized`, 426 `till_update_required`) or an abort stops the pass.
|
|
35
|
+
* A 403 `forbidden` is store-class: the catalogue reconcile skips this check and tries again at the next hourly check.
|
|
32
36
|
*/
|
|
33
37
|
export async function wooHasIdFastPath(context: SyncContext): Promise<boolean> {
|
|
34
38
|
let status: any;
|
|
@@ -42,19 +46,21 @@ export async function wooHasIdFastPath(context: SyncContext): Promise<boolean> {
|
|
|
42
46
|
}
|
|
43
47
|
|
|
44
48
|
/**
|
|
45
|
-
* The fast path's one request
|
|
46
|
-
*
|
|
49
|
+
* The fast path's one request (wcpos/woocommerce-pos #2116, #2119, #2121): WordPress's `_fields` with exactly these
|
|
50
|
+
* four, answered from SQL, published POS-visible products only. The context headers carry `X-WCPOS`.
|
|
47
51
|
*/
|
|
48
52
|
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}`;
|
|
53
|
+
return `${baseUrl}/products?${new URLSearchParams({ per_page: '-1', _fields: LISTING_FIELDS })}`;
|
|
52
54
|
}
|
|
53
55
|
|
|
56
|
+
/** Listing rows as entries keyed by the numeric id (#313); a row without an integer id cannot be asked about, and is dropped. */
|
|
57
|
+
const toEntries = (rows: any[]) => rows.filter((row) => Number.isInteger(row?.id))
|
|
58
|
+
.map((row): CatalogueReconcileEntry => ({ key: String(row.id), fingerprint: wooReconcileFingerprint(row), remote: row.id }));
|
|
59
|
+
|
|
54
60
|
/**
|
|
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.
|
|
61
|
+
* The daily catalogue check for WooCommerce (#248): lists every published product with its fingerprint, in one
|
|
62
|
+
* fast-path request where the store offers it (#313), else page by page, with no date filter, and hands differences
|
|
63
|
+
* to `feed`, which reaches the collection only through the pull. `confirmGone` is the deletion proof: a by-id re-read.
|
|
58
64
|
*/
|
|
59
65
|
export function wooCatalogueReconcile(feed: Pick<ReconcileFeed<any>, 'enqueue'>): CatalogueReconcileAdapter<any, number> {
|
|
60
66
|
return {
|
|
@@ -63,9 +69,23 @@ export function wooCatalogueReconcile(feed: Pick<ReconcileFeed<any>, 'enqueue'>)
|
|
|
63
69
|
const fast = await wooHasIdFastPath(context);
|
|
64
70
|
yield { entries: [], cursor: from };
|
|
65
71
|
if (fast) {
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
72
|
+
// The whole catalogue in one request, so `from` is moot. A store that refuses it (a store scope answers 400)
|
|
73
|
+
// falls back to the paged listing, which fails the same way. A 403 `forbidden` is skipped until the next hourly
|
|
74
|
+
// check; only a till-class error or an abort stops the pass.
|
|
75
|
+
let rows: any;
|
|
76
|
+
let failed = false;
|
|
77
|
+
try {
|
|
78
|
+
rows = await get(wooBulkListingUrl(''), context); // get() prefixes context.baseUrl
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (errorKind(error) === 'till' || context.signal?.aborted) throw error;
|
|
81
|
+
failed = true;
|
|
82
|
+
console.warn(`WooCommerce catalogue reconcile: the fast path failed (${(error as Error)?.message ?? error}); listing page by page.`);
|
|
83
|
+
}
|
|
84
|
+
if (Array.isArray(rows)) {
|
|
85
|
+
yield { entries: toEntries(rows), cursor: from };
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (!failed) console.warn('WooCommerce catalogue reconcile: the fast path answered with something that is not a list; listing page by page.');
|
|
69
89
|
}
|
|
70
90
|
// status=publish: a product that is not published is absent, so it becomes a deletion candidate and
|
|
71
91
|
// confirmGone decides; drafts cost no daily refetch.
|
|
@@ -74,15 +94,13 @@ export function wooCatalogueReconcile(feed: Pick<ReconcileFeed<any>, 'enqueue'>)
|
|
|
74
94
|
per_page: String(PAGE_SIZE), page: String(page), orderby: 'id', order: 'asc', status: 'publish', _fields: LISTING_FIELDS,
|
|
75
95
|
});
|
|
76
96
|
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 };
|
|
97
|
+
yield { entries: toEntries(rows), cursor: page + 1 };
|
|
82
98
|
if (rows.length < PAGE_SIZE) return;
|
|
83
99
|
}
|
|
84
100
|
},
|
|
85
101
|
fingerprint: wooReconcileFingerprint,
|
|
102
|
+
// Both listings key on the numeric id, never the till-local uuid (#313).
|
|
103
|
+
matchKey: (doc) => (Number.isInteger(doc.id) ? String(doc.id) : undefined),
|
|
86
104
|
async confirmGone(locals, context) {
|
|
87
105
|
const ids = locals.map((doc) => doc.id).filter((id) => Number.isInteger(id));
|
|
88
106
|
// 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,10 @@ 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
|
-
|
|
24
|
+
// Older WCPOS versions leak the JWT plugin's stale 403 despite a valid token.
|
|
25
|
+
const body = response.status === 403 ? await response.json().catch(() => undefined) : undefined;
|
|
26
|
+
if (typeof body?.code === 'string' && body.code.startsWith('jwt_auth_')) throw new WooPluginUpdateRequiredError(body.code);
|
|
27
|
+
throw new ConnectorUnauthorizedError(`WooCommerce API error: ${response.status}`, response.status as 401 | 403);
|
|
25
28
|
}
|
|
26
29
|
if (response.status === 426) {
|
|
27
30
|
// Only the plugin's own gate means the till needs updating; any other 426 is retried as transient, with the store's message.
|
|
@@ -87,6 +90,19 @@ export class WooDateFilterError extends Error {
|
|
|
87
90
|
}
|
|
88
91
|
}
|
|
89
92
|
|
|
93
|
+
/** A stale JWT plugin 403 on WCPOS 1.10.0–1.10.7 (wcpos/woocommerce-pos#1863; fixed by 7243675c9). */
|
|
94
|
+
export class WooPluginUpdateRequiredError extends Error {
|
|
95
|
+
name = 'WooPluginUpdateRequiredError';
|
|
96
|
+
readonly code = 'unsupported_store' as const;
|
|
97
|
+
readonly fixedBy = 'store' as const;
|
|
98
|
+
readonly software = 'WCPOS';
|
|
99
|
+
readonly minVersion = '1.10.8';
|
|
100
|
+
|
|
101
|
+
constructor(readonly storeCode: string) {
|
|
102
|
+
super('This store needs WCPOS 1.10.8 or later: the JWT Authentication plugin blocks the till on older versions.');
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
90
106
|
/**
|
|
91
107
|
* Replication adapter for WooCommerce products.
|
|
92
108
|
*
|