@tallyui/connector-woocommerce 1.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/LICENSE +21 -0
- package/README.md +40 -0
- package/dist/index.d.ts +79 -10
- package/dist/index.js +270 -61
- package/package.json +12 -4
- package/src/index.ts +63 -24
- package/src/reconcile/catalogue.ts +99 -0
- package/src/reconcile/feed.ts +34 -0
- package/src/replication/products.ts +146 -59
- package/src/sync/products.ts +6 -1
- package/src/traits/product.ts +29 -1
- package/src/__tests__/product-traits.test.ts +0 -191
- package/src/replication/products.test.ts +0 -189
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Paul Kilmurray and TallyUI contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
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
|
-
|
|
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
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
75
|
+
* Pull-only (id-ordered offset pages within a date_modified_gmt window) for RxDB's
|
|
76
|
+
* replicateRxCollection. Catalogue data is server-owned; the POS never
|
|
77
|
+
* writes products.
|
|
38
78
|
*/
|
|
39
79
|
declare const wooProductReplication: ReplicationAdapter<any, WooProductCheckpoint>;
|
|
40
80
|
|
|
41
81
|
/**
|
|
42
|
-
*
|
|
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
|
-
*
|
|
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 {
|
|
111
|
+
* import { createWooCommerceConnector } from '@tallyui/connector-woocommerce';
|
|
49
112
|
* import { ConnectorProvider } from '@tallyui/core';
|
|
50
113
|
*
|
|
51
|
-
*
|
|
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",
|
|
@@ -144,10 +147,29 @@ var wooProductSchema = {
|
|
|
144
147
|
};
|
|
145
148
|
|
|
146
149
|
// src/traits/product.ts
|
|
150
|
+
import { moneyFromMajor } from "@tallyui/core";
|
|
151
|
+
var WOO_STOCK_STATUS = {
|
|
152
|
+
instock: "in_stock",
|
|
153
|
+
outofstock: "out_of_stock",
|
|
154
|
+
onbackorder: "backorder"
|
|
155
|
+
};
|
|
147
156
|
var wooProductTraits = {
|
|
148
157
|
getId: (doc) => String(doc.id),
|
|
149
158
|
getName: (doc) => doc.name ?? "",
|
|
150
159
|
getSku: (doc) => doc.sku || void 0,
|
|
160
|
+
getPrices: (doc, context) => {
|
|
161
|
+
const currency = context?.currency ?? "XXX";
|
|
162
|
+
const prices = [];
|
|
163
|
+
const base = moneyFromMajor(doc.regular_price || doc.price, currency);
|
|
164
|
+
if (base) prices.push({ ...base, kind: "base" });
|
|
165
|
+
const sale = doc.on_sale === true ? moneyFromMajor(doc.sale_price, currency) : void 0;
|
|
166
|
+
if (sale) prices.push({ ...sale, kind: "sale" });
|
|
167
|
+
return prices;
|
|
168
|
+
},
|
|
169
|
+
getStock: (doc) => ({
|
|
170
|
+
status: WOO_STOCK_STATUS[doc.stock_status] ?? "unknown",
|
|
171
|
+
quantity: doc.manage_stock === false ? void 0 : doc.stock_quantity ?? void 0
|
|
172
|
+
}),
|
|
151
173
|
getPrice: (doc) => doc.price || void 0,
|
|
152
174
|
getRegularPrice: (doc) => doc.regular_price || void 0,
|
|
153
175
|
getSalePrice: (doc) => doc.sale_price || void 0,
|
|
@@ -164,6 +186,8 @@ var wooProductTraits = {
|
|
|
164
186
|
},
|
|
165
187
|
getStockQuantity: (doc) => doc.stock_quantity ?? null,
|
|
166
188
|
hasVariants: (doc) => doc.type === "variable",
|
|
189
|
+
isSellable: (doc) => doc.status === void 0 || doc.status === "publish",
|
|
190
|
+
getVariantCount: (doc) => doc.type === "variable" ? doc.variations?.length ?? 0 : 1,
|
|
167
191
|
getType: (doc) => doc.type ?? "simple",
|
|
168
192
|
getBarcode: (doc) => doc.barcode || void 0,
|
|
169
193
|
getCategoryNames: (doc) => (doc.categories ?? []).map((cat) => cat.name).filter(Boolean)
|
|
@@ -206,8 +230,13 @@ var wooProductSync = {
|
|
|
206
230
|
return response.json();
|
|
207
231
|
},
|
|
208
232
|
async fetchModifiedAfter(date, context) {
|
|
233
|
+
const params = new URLSearchParams({
|
|
234
|
+
modified_after: date,
|
|
235
|
+
dates_are_gmt: "true",
|
|
236
|
+
per_page: "100"
|
|
237
|
+
});
|
|
209
238
|
const response = await fetch(
|
|
210
|
-
`${context.baseUrl}/products
|
|
239
|
+
`${context.baseUrl}/products?${params}`,
|
|
211
240
|
{
|
|
212
241
|
headers: {
|
|
213
242
|
...context.headers,
|
|
@@ -224,17 +253,98 @@ var wooProductSync = {
|
|
|
224
253
|
};
|
|
225
254
|
|
|
226
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
|
+
};
|
|
227
316
|
var wooProductReplication = {
|
|
228
317
|
pull: {
|
|
229
|
-
async
|
|
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
|
+
}
|
|
230
337
|
const params = new URLSearchParams({
|
|
231
338
|
per_page: String(batchSize),
|
|
232
|
-
|
|
339
|
+
offset: String(offset),
|
|
340
|
+
orderby: "id",
|
|
233
341
|
order: "asc"
|
|
234
342
|
});
|
|
235
|
-
if (
|
|
236
|
-
params.set("modified_after",
|
|
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");
|
|
237
346
|
}
|
|
347
|
+
if (requests++ >= MAX_REQUESTS_PER_CALL) return spent;
|
|
238
348
|
const response = await fetch(`${context.baseUrl}/products?${params}`, {
|
|
239
349
|
headers: {
|
|
240
350
|
...context.headers,
|
|
@@ -242,61 +352,154 @@ var wooProductReplication = {
|
|
|
242
352
|
},
|
|
243
353
|
signal: context.signal
|
|
244
354
|
});
|
|
245
|
-
|
|
246
|
-
throw new Error(`WooCommerce API error: ${response.status}`);
|
|
247
|
-
}
|
|
355
|
+
await checkResponse(response);
|
|
248
356
|
const products = await response.json();
|
|
249
|
-
const
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
modified:
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
async handler(changeRows, context) {
|
|
259
|
-
const conflicts = [];
|
|
260
|
-
for (const row of changeRows) {
|
|
261
|
-
const doc = row.newDocumentState;
|
|
262
|
-
try {
|
|
263
|
-
const isCreate = !row.assumedMasterState;
|
|
264
|
-
const method = isCreate ? "POST" : "PUT";
|
|
265
|
-
const url = isCreate ? `${context.baseUrl}/products` : `${context.baseUrl}/products/${doc.id}`;
|
|
266
|
-
const response = await fetch(url, {
|
|
267
|
-
method,
|
|
268
|
-
headers: {
|
|
269
|
-
...context.headers,
|
|
270
|
-
"Content-Type": "application/json"
|
|
271
|
-
},
|
|
272
|
-
body: JSON.stringify(doc),
|
|
273
|
-
signal: context.signal
|
|
274
|
-
});
|
|
275
|
-
if (!response.ok) {
|
|
276
|
-
if (row.assumedMasterState) {
|
|
277
|
-
conflicts.push({ ...row.assumedMasterState, _deleted: false });
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
} catch {
|
|
281
|
-
if (row.assumedMasterState) {
|
|
282
|
-
conflicts.push({ ...row.assumedMasterState, _deleted: false });
|
|
283
|
-
}
|
|
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);
|
|
284
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 };
|
|
285
374
|
}
|
|
286
|
-
return
|
|
375
|
+
return { documents, checkpoint };
|
|
287
376
|
}
|
|
288
377
|
}
|
|
289
378
|
};
|
|
290
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
|
+
|
|
291
469
|
// src/index.ts
|
|
292
|
-
|
|
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 = {
|
|
293
496
|
id: "woocommerce",
|
|
294
497
|
name: "WooCommerce",
|
|
295
498
|
description: "Connect to WooCommerce stores via the REST API",
|
|
296
499
|
icon: void 0,
|
|
297
500
|
// TODO: WooCommerce logo
|
|
298
501
|
auth: {
|
|
299
|
-
type: "
|
|
502
|
+
type: "WCPOS token",
|
|
300
503
|
fields: [
|
|
301
504
|
{
|
|
302
505
|
key: "url",
|
|
@@ -306,24 +509,23 @@ var woocommerceConnector = {
|
|
|
306
509
|
required: true
|
|
307
510
|
},
|
|
308
511
|
{
|
|
309
|
-
key: "
|
|
310
|
-
label: "
|
|
311
|
-
type: "text",
|
|
312
|
-
placeholder: "ck_...",
|
|
313
|
-
required: true
|
|
314
|
-
},
|
|
315
|
-
{
|
|
316
|
-
key: "consumer_secret",
|
|
317
|
-
label: "Consumer Secret",
|
|
512
|
+
key: "token",
|
|
513
|
+
label: "Access token",
|
|
318
514
|
type: "password",
|
|
319
|
-
placeholder: "cs_...",
|
|
320
515
|
required: true
|
|
321
516
|
}
|
|
322
517
|
],
|
|
323
518
|
getHeaders: (credentials) => {
|
|
324
|
-
|
|
519
|
+
if (typeof credentials.token !== "string" || credentials.token.length === 0) {
|
|
520
|
+
throw new WooMissingTokenError();
|
|
521
|
+
}
|
|
325
522
|
return {
|
|
326
|
-
Authorization: `
|
|
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)}`
|
|
327
529
|
};
|
|
328
530
|
}
|
|
329
531
|
},
|
|
@@ -335,15 +537,22 @@ var woocommerceConnector = {
|
|
|
335
537
|
},
|
|
336
538
|
sync: {
|
|
337
539
|
products: wooProductSync
|
|
338
|
-
},
|
|
339
|
-
replication: {
|
|
340
|
-
products: wooProductReplication
|
|
341
540
|
}
|
|
342
541
|
};
|
|
542
|
+
var woocommerceConnector = createWooCommerceConnector();
|
|
343
543
|
export {
|
|
544
|
+
ConnectorUnauthorizedError3 as ConnectorUnauthorizedError,
|
|
545
|
+
WooDateFilterError,
|
|
546
|
+
WooMissingTokenError,
|
|
547
|
+
WooMissingUuidError,
|
|
548
|
+
WooTillUpdateRequiredError,
|
|
549
|
+
createWooCommerceConnector,
|
|
550
|
+
wcposClientPart,
|
|
551
|
+
wooCatalogueReconcile,
|
|
344
552
|
wooProductReplication,
|
|
345
553
|
wooProductSchema,
|
|
346
554
|
wooProductSync,
|
|
347
555
|
wooProductTraits,
|
|
556
|
+
wooReconcileFingerprint,
|
|
348
557
|
woocommerceConnector
|
|
349
558
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tallyui/connector-woocommerce",
|
|
3
|
-
"version": "
|
|
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",
|
|
@@ -15,14 +15,22 @@
|
|
|
15
15
|
},
|
|
16
16
|
"files": [
|
|
17
17
|
"dist",
|
|
18
|
-
"src"
|
|
18
|
+
"src",
|
|
19
|
+
"!src/**/*.test.ts",
|
|
20
|
+
"!src/**/__tests__/**"
|
|
19
21
|
],
|
|
20
22
|
"license": "MIT",
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/TallyUI/tallyui.git",
|
|
26
|
+
"directory": "connectors/woocommerce"
|
|
27
|
+
},
|
|
21
28
|
"peerDependencies": {
|
|
22
|
-
"@tallyui/core": "0.
|
|
29
|
+
"@tallyui/core": "^3.0.0-next.0"
|
|
23
30
|
},
|
|
24
31
|
"devDependencies": {
|
|
25
|
-
"
|
|
32
|
+
"@tallyui/database": "3.0.0-next.0",
|
|
33
|
+
"rxdb": "17.5.0"
|
|
26
34
|
},
|
|
27
35
|
"scripts": {
|
|
28
36
|
"build": "tsup",
|