@tallyui/connector-woocommerce 1.0.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.
@@ -0,0 +1,58 @@
1
+ import { ProductTraits, CollectionSync, ReplicationAdapter, TallyConnector } from '@tallyui/core';
2
+ import { RxJsonSchema } from 'rxdb';
3
+
4
+ /**
5
+ * WooCommerce Product RxDB schema.
6
+ * Closely mirrors the WooCommerce REST API product shape.
7
+ * Ported from @wcpos/database.
8
+ */
9
+ declare const wooProductSchema: RxJsonSchema<any>;
10
+
11
+ /**
12
+ * WooCommerce product trait implementations.
13
+ *
14
+ * Maps the WooCommerce REST API product shape to the universal
15
+ * Tally UI product interface.
16
+ */
17
+ declare const wooProductTraits: ProductTraits;
18
+
19
+ /**
20
+ * WooCommerce product sync implementation.
21
+ *
22
+ * Uses the WooCommerce REST API (or WCPOS API) to fetch products.
23
+ * For the MVP, this is a simple implementation that fetches all products.
24
+ * The full WCPOS sync engine has much more sophisticated diffing/queueing.
25
+ */
26
+ declare const wooProductSync: CollectionSync;
27
+
28
+ type WooProductCheckpoint = {
29
+ id: string;
30
+ modified: string;
31
+ };
32
+ /**
33
+ * Replication adapter for WooCommerce products.
34
+ *
35
+ * Implements pull (cursor-based pagination by date_modified_gmt) and push
36
+ * (individual create/update via WC REST API v3). Designed for use with
37
+ * RxDB's replicateRxCollection.
38
+ */
39
+ declare const wooProductReplication: ReplicationAdapter<any, WooProductCheckpoint>;
40
+
41
+ /**
42
+ * WooCommerce connector for Tally UI.
43
+ *
44
+ * Connects to WooCommerce sites via the REST API (v3) or WCPOS API.
45
+ * Products are stored in RxDB using a schema that mirrors the WC API shape.
46
+ *
47
+ * ```ts
48
+ * import { woocommerceConnector } from '@tallyui/connector-woocommerce';
49
+ * import { ConnectorProvider } from '@tallyui/core';
50
+ *
51
+ * <ConnectorProvider connector={woocommerceConnector}>
52
+ * <App />
53
+ * </ConnectorProvider>
54
+ * ```
55
+ */
56
+ declare const woocommerceConnector: TallyConnector;
57
+
58
+ export { wooProductReplication, wooProductSchema, wooProductSync, wooProductTraits, woocommerceConnector };
package/dist/index.js ADDED
@@ -0,0 +1,349 @@
1
+ // src/schemas/products.ts
2
+ var wooProductSchema = {
3
+ title: "WooCommerce Product",
4
+ version: 0,
5
+ type: "object",
6
+ primaryKey: "uuid",
7
+ properties: {
8
+ uuid: {
9
+ type: "string",
10
+ maxLength: 36
11
+ },
12
+ id: {
13
+ type: "integer",
14
+ multipleOf: 1,
15
+ minimum: 0,
16
+ maximum: 2147483647
17
+ },
18
+ name: {
19
+ type: "string"
20
+ },
21
+ slug: {
22
+ type: "string"
23
+ },
24
+ type: {
25
+ type: "string",
26
+ default: "simple",
27
+ enum: ["simple", "grouped", "external", "variable"]
28
+ },
29
+ status: {
30
+ type: "string",
31
+ default: "publish",
32
+ enum: ["draft", "pending", "private", "publish"]
33
+ },
34
+ sku: {
35
+ type: "string"
36
+ },
37
+ barcode: {
38
+ type: "string",
39
+ maxLength: 255
40
+ },
41
+ price: {
42
+ type: "string"
43
+ },
44
+ regular_price: {
45
+ type: "string"
46
+ },
47
+ sale_price: {
48
+ type: "string"
49
+ },
50
+ on_sale: {
51
+ type: "boolean"
52
+ },
53
+ description: {
54
+ type: "string"
55
+ },
56
+ short_description: {
57
+ type: "string"
58
+ },
59
+ stock_quantity: {
60
+ type: ["number", "null"]
61
+ },
62
+ stock_status: {
63
+ type: "string",
64
+ default: "instock",
65
+ enum: ["instock", "outofstock", "onbackorder", "lowstock"],
66
+ maxLength: 255
67
+ },
68
+ manage_stock: {
69
+ type: "boolean",
70
+ default: false
71
+ },
72
+ categories: {
73
+ type: "array",
74
+ items: {
75
+ type: "object",
76
+ properties: {
77
+ id: { type: "integer" },
78
+ name: { type: "string" },
79
+ slug: { type: "string" }
80
+ }
81
+ }
82
+ },
83
+ tags: {
84
+ type: "array",
85
+ items: {
86
+ type: "object",
87
+ properties: {
88
+ id: { type: "integer" },
89
+ name: { type: "string" },
90
+ slug: { type: "string" }
91
+ }
92
+ }
93
+ },
94
+ images: {
95
+ type: "array",
96
+ items: {
97
+ type: "object",
98
+ properties: {
99
+ id: { type: "integer" },
100
+ src: { type: "string" },
101
+ name: { type: "string" },
102
+ alt: { type: "string" }
103
+ }
104
+ }
105
+ },
106
+ attributes: {
107
+ type: "array",
108
+ items: {
109
+ type: "object",
110
+ properties: {
111
+ id: { type: "integer" },
112
+ name: { type: "string" },
113
+ position: { type: "integer" },
114
+ visible: { type: "boolean" },
115
+ variation: { type: "boolean" },
116
+ options: {
117
+ type: "array",
118
+ items: { type: "string" }
119
+ }
120
+ }
121
+ }
122
+ },
123
+ variations: {
124
+ type: "array",
125
+ items: { type: "integer" }
126
+ },
127
+ meta_data: {
128
+ type: "array",
129
+ items: {
130
+ type: "object",
131
+ properties: {
132
+ id: { type: "integer" },
133
+ key: { type: "string" },
134
+ value: { type: "string" }
135
+ }
136
+ }
137
+ },
138
+ date_modified_gmt: {
139
+ type: "string"
140
+ }
141
+ },
142
+ indexes: ["id", "barcode", "stock_status"],
143
+ required: ["uuid"]
144
+ };
145
+
146
+ // src/traits/product.ts
147
+ var wooProductTraits = {
148
+ getId: (doc) => String(doc.id),
149
+ getName: (doc) => doc.name ?? "",
150
+ getSku: (doc) => doc.sku || void 0,
151
+ getPrice: (doc) => doc.price || void 0,
152
+ getRegularPrice: (doc) => doc.regular_price || void 0,
153
+ getSalePrice: (doc) => doc.sale_price || void 0,
154
+ isOnSale: (doc) => doc.on_sale === true,
155
+ getImageUrl: (doc) => doc.images?.[0]?.src || void 0,
156
+ getImageUrls: (doc) => (doc.images ?? []).map((img) => img.src).filter(Boolean),
157
+ getDescription: (doc) => doc.short_description || doc.description || void 0,
158
+ getStockStatus: (doc) => {
159
+ const status = doc.stock_status;
160
+ if (status === "instock" || status === "outofstock" || status === "onbackorder") {
161
+ return status;
162
+ }
163
+ return "unknown";
164
+ },
165
+ getStockQuantity: (doc) => doc.stock_quantity ?? null,
166
+ hasVariants: (doc) => doc.type === "variable",
167
+ getType: (doc) => doc.type ?? "simple",
168
+ getBarcode: (doc) => doc.barcode || void 0,
169
+ getCategoryNames: (doc) => (doc.categories ?? []).map((cat) => cat.name).filter(Boolean)
170
+ };
171
+
172
+ // src/sync/products.ts
173
+ var wooProductSync = {
174
+ async fetchAllIds(context) {
175
+ const response = await fetch(`${context.baseUrl}/products`, {
176
+ headers: {
177
+ ...context.headers,
178
+ "Content-Type": "application/json"
179
+ },
180
+ signal: context.signal
181
+ });
182
+ if (!response.ok) {
183
+ throw new Error(`WooCommerce API error: ${response.status}`);
184
+ }
185
+ const products = await response.json();
186
+ return products.map((p) => ({
187
+ id: String(p.id),
188
+ dateModified: p.date_modified_gmt
189
+ }));
190
+ },
191
+ async fetchByIds(ids, context) {
192
+ const include = ids.join(",");
193
+ const response = await fetch(
194
+ `${context.baseUrl}/products?include=${include}&per_page=${ids.length}`,
195
+ {
196
+ headers: {
197
+ ...context.headers,
198
+ "Content-Type": "application/json"
199
+ },
200
+ signal: context.signal
201
+ }
202
+ );
203
+ if (!response.ok) {
204
+ throw new Error(`WooCommerce API error: ${response.status}`);
205
+ }
206
+ return response.json();
207
+ },
208
+ async fetchModifiedAfter(date, context) {
209
+ const response = await fetch(
210
+ `${context.baseUrl}/products?modified_after=${date}&per_page=100`,
211
+ {
212
+ headers: {
213
+ ...context.headers,
214
+ "Content-Type": "application/json"
215
+ },
216
+ signal: context.signal
217
+ }
218
+ );
219
+ if (!response.ok) {
220
+ throw new Error(`WooCommerce API error: ${response.status}`);
221
+ }
222
+ return response.json();
223
+ }
224
+ };
225
+
226
+ // src/replication/products.ts
227
+ var wooProductReplication = {
228
+ pull: {
229
+ async handler(lastCheckpoint, batchSize, context) {
230
+ const params = new URLSearchParams({
231
+ per_page: String(batchSize),
232
+ orderby: "modified",
233
+ order: "asc"
234
+ });
235
+ if (lastCheckpoint?.modified) {
236
+ params.set("modified_after", lastCheckpoint.modified);
237
+ }
238
+ const response = await fetch(`${context.baseUrl}/products?${params}`, {
239
+ headers: {
240
+ ...context.headers,
241
+ "Content-Type": "application/json"
242
+ },
243
+ signal: context.signal
244
+ });
245
+ if (!response.ok) {
246
+ throw new Error(`WooCommerce API error: ${response.status}`);
247
+ }
248
+ const products = await response.json();
249
+ const documents = products.map((p) => ({ ...p, _deleted: false }));
250
+ const checkpoint = products.length > 0 ? {
251
+ id: String(products[products.length - 1].uuid ?? products[products.length - 1].id),
252
+ modified: products[products.length - 1].date_modified_gmt
253
+ } : lastCheckpoint ?? { id: "", modified: "" };
254
+ return { documents, checkpoint };
255
+ }
256
+ },
257
+ push: {
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
+ }
284
+ }
285
+ }
286
+ return conflicts;
287
+ }
288
+ }
289
+ };
290
+
291
+ // src/index.ts
292
+ var woocommerceConnector = {
293
+ id: "woocommerce",
294
+ name: "WooCommerce",
295
+ description: "Connect to WooCommerce stores via the REST API",
296
+ icon: void 0,
297
+ // TODO: WooCommerce logo
298
+ auth: {
299
+ type: "WooCommerce REST API",
300
+ fields: [
301
+ {
302
+ key: "url",
303
+ label: "Store URL",
304
+ type: "url",
305
+ placeholder: "https://mystore.com",
306
+ required: true
307
+ },
308
+ {
309
+ key: "consumer_key",
310
+ label: "Consumer Key",
311
+ type: "text",
312
+ placeholder: "ck_...",
313
+ required: true
314
+ },
315
+ {
316
+ key: "consumer_secret",
317
+ label: "Consumer Secret",
318
+ type: "password",
319
+ placeholder: "cs_...",
320
+ required: true
321
+ }
322
+ ],
323
+ getHeaders: (credentials) => {
324
+ const encoded = btoa(`${credentials.consumer_key}:${credentials.consumer_secret}`);
325
+ return {
326
+ Authorization: `Basic ${encoded}`
327
+ };
328
+ }
329
+ },
330
+ schemas: {
331
+ products: wooProductSchema
332
+ },
333
+ traits: {
334
+ product: wooProductTraits
335
+ },
336
+ sync: {
337
+ products: wooProductSync
338
+ },
339
+ replication: {
340
+ products: wooProductReplication
341
+ }
342
+ };
343
+ export {
344
+ wooProductReplication,
345
+ wooProductSchema,
346
+ wooProductSync,
347
+ wooProductTraits,
348
+ woocommerceConnector
349
+ };
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@tallyui/connector-woocommerce",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "WooCommerce connector for Tally UI",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "source": "./src/index.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js",
13
+ "source": "./src/index.ts"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "src"
19
+ ],
20
+ "license": "MIT",
21
+ "peerDependencies": {
22
+ "@tallyui/core": "0.2.0"
23
+ },
24
+ "devDependencies": {
25
+ "rxdb": "16.21.1"
26
+ },
27
+ "scripts": {
28
+ "build": "tsup",
29
+ "typecheck": "tsc --noEmit",
30
+ "test": "echo \"no tests yet\""
31
+ }
32
+ }
@@ -0,0 +1,191 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { wooProductTraits } from '../traits/product';
3
+
4
+ /**
5
+ * Realistic WooCommerce product document, shaped like the REST API v3 response.
6
+ */
7
+ const fullProduct = {
8
+ id: 42,
9
+ name: 'Espresso Machine Pro',
10
+ slug: 'espresso-machine-pro',
11
+ type: 'simple',
12
+ status: 'publish',
13
+ sku: 'ESP-001',
14
+ price: '599.99',
15
+ regular_price: '599.99',
16
+ sale_price: '',
17
+ on_sale: false,
18
+ stock_status: 'instock',
19
+ stock_quantity: 15,
20
+ barcode: '1234567890123',
21
+ short_description: 'High-end espresso machine.',
22
+ description: 'Full description here.',
23
+ images: [
24
+ { id: 1, src: 'https://store.example/espresso.jpg', alt: 'Espresso Machine' },
25
+ { id: 2, src: 'https://store.example/espresso-side.jpg', alt: '' },
26
+ ],
27
+ categories: [
28
+ { id: 1, name: 'Equipment', slug: 'equipment' },
29
+ { id: 2, name: 'Coffee', slug: 'coffee' },
30
+ ],
31
+ };
32
+
33
+ const saleProduct = {
34
+ ...fullProduct,
35
+ id: 43,
36
+ price: '499.99',
37
+ sale_price: '499.99',
38
+ on_sale: true,
39
+ };
40
+
41
+ const variableProduct = {
42
+ ...fullProduct,
43
+ id: 44,
44
+ type: 'variable',
45
+ variations: [101, 102],
46
+ };
47
+
48
+ const minimalProduct = {
49
+ id: 99,
50
+ };
51
+
52
+ describe('WooCommerce product traits', () => {
53
+ describe('getId', () => {
54
+ it('returns the product id as a string', () => {
55
+ expect(wooProductTraits.getId(fullProduct)).toBe('42');
56
+ });
57
+ });
58
+
59
+ describe('getName', () => {
60
+ it('returns the product name', () => {
61
+ expect(wooProductTraits.getName(fullProduct)).toBe('Espresso Machine Pro');
62
+ });
63
+
64
+ it('returns empty string for missing name', () => {
65
+ expect(wooProductTraits.getName(minimalProduct)).toBe('');
66
+ });
67
+ });
68
+
69
+ describe('getSku', () => {
70
+ it('returns the SKU', () => {
71
+ expect(wooProductTraits.getSku(fullProduct)).toBe('ESP-001');
72
+ });
73
+
74
+ it('returns undefined when no SKU', () => {
75
+ expect(wooProductTraits.getSku(minimalProduct)).toBeUndefined();
76
+ });
77
+ });
78
+
79
+ describe('getPrice', () => {
80
+ it('returns the price string', () => {
81
+ expect(wooProductTraits.getPrice(fullProduct)).toBe('599.99');
82
+ });
83
+
84
+ it('returns undefined for missing price', () => {
85
+ expect(wooProductTraits.getPrice(minimalProduct)).toBeUndefined();
86
+ });
87
+ });
88
+
89
+ describe('getRegularPrice / getSalePrice / isOnSale', () => {
90
+ it('returns regular price', () => {
91
+ expect(wooProductTraits.getRegularPrice(fullProduct)).toBe('599.99');
92
+ });
93
+
94
+ it('detects non-sale product', () => {
95
+ expect(wooProductTraits.isOnSale(fullProduct)).toBe(false);
96
+ expect(wooProductTraits.getSalePrice(fullProduct)).toBeUndefined();
97
+ });
98
+
99
+ it('detects sale product', () => {
100
+ expect(wooProductTraits.isOnSale(saleProduct)).toBe(true);
101
+ expect(wooProductTraits.getSalePrice(saleProduct)).toBe('499.99');
102
+ });
103
+ });
104
+
105
+ describe('getImageUrl / getImageUrls', () => {
106
+ it('returns the first image src', () => {
107
+ expect(wooProductTraits.getImageUrl(fullProduct)).toBe('https://store.example/espresso.jpg');
108
+ });
109
+
110
+ it('returns all image URLs', () => {
111
+ expect(wooProductTraits.getImageUrls(fullProduct)).toEqual([
112
+ 'https://store.example/espresso.jpg',
113
+ 'https://store.example/espresso-side.jpg',
114
+ ]);
115
+ });
116
+
117
+ it('returns undefined / empty for missing images', () => {
118
+ expect(wooProductTraits.getImageUrl(minimalProduct)).toBeUndefined();
119
+ expect(wooProductTraits.getImageUrls(minimalProduct)).toEqual([]);
120
+ });
121
+ });
122
+
123
+ describe('getDescription', () => {
124
+ it('prefers short_description', () => {
125
+ expect(wooProductTraits.getDescription(fullProduct)).toBe('High-end espresso machine.');
126
+ });
127
+
128
+ it('falls back to description', () => {
129
+ const doc = { ...fullProduct, short_description: '' };
130
+ expect(wooProductTraits.getDescription(doc)).toBe('Full description here.');
131
+ });
132
+
133
+ it('returns undefined when neither exists', () => {
134
+ expect(wooProductTraits.getDescription(minimalProduct)).toBeUndefined();
135
+ });
136
+ });
137
+
138
+ describe('getStockStatus / getStockQuantity', () => {
139
+ it('returns instock status', () => {
140
+ expect(wooProductTraits.getStockStatus(fullProduct)).toBe('instock');
141
+ });
142
+
143
+ it('returns stock quantity', () => {
144
+ expect(wooProductTraits.getStockQuantity(fullProduct)).toBe(15);
145
+ });
146
+
147
+ it('returns unknown for unrecognised status', () => {
148
+ expect(wooProductTraits.getStockStatus({ ...fullProduct, stock_status: 'weird' })).toBe('unknown');
149
+ });
150
+
151
+ it('returns null quantity when missing', () => {
152
+ expect(wooProductTraits.getStockQuantity(minimalProduct)).toBeNull();
153
+ });
154
+ });
155
+
156
+ describe('hasVariants / getType', () => {
157
+ it('simple product has no variants', () => {
158
+ expect(wooProductTraits.hasVariants(fullProduct)).toBe(false);
159
+ expect(wooProductTraits.getType(fullProduct)).toBe('simple');
160
+ });
161
+
162
+ it('variable product has variants', () => {
163
+ expect(wooProductTraits.hasVariants(variableProduct)).toBe(true);
164
+ expect(wooProductTraits.getType(variableProduct)).toBe('variable');
165
+ });
166
+
167
+ it('defaults to simple for missing type', () => {
168
+ expect(wooProductTraits.getType(minimalProduct)).toBe('simple');
169
+ });
170
+ });
171
+
172
+ describe('getBarcode', () => {
173
+ it('returns barcode', () => {
174
+ expect(wooProductTraits.getBarcode(fullProduct)).toBe('1234567890123');
175
+ });
176
+
177
+ it('returns undefined when missing', () => {
178
+ expect(wooProductTraits.getBarcode(minimalProduct)).toBeUndefined();
179
+ });
180
+ });
181
+
182
+ describe('getCategoryNames', () => {
183
+ it('returns category name strings', () => {
184
+ expect(wooProductTraits.getCategoryNames(fullProduct)).toEqual(['Equipment', 'Coffee']);
185
+ });
186
+
187
+ it('returns empty array when no categories', () => {
188
+ expect(wooProductTraits.getCategoryNames(minimalProduct)).toEqual([]);
189
+ });
190
+ });
191
+ });
package/src/index.ts ADDED
@@ -0,0 +1,83 @@
1
+ import type { TallyConnector } from '@tallyui/core';
2
+
3
+ import { wooProductSchema } from './schemas/products';
4
+ import { wooProductTraits } from './traits/product';
5
+ import { wooProductSync } from './sync/products';
6
+ import { wooProductReplication } from './replication/products';
7
+
8
+ /**
9
+ * WooCommerce connector for Tally UI.
10
+ *
11
+ * Connects to WooCommerce sites via the REST API (v3) or WCPOS API.
12
+ * Products are stored in RxDB using a schema that mirrors the WC API shape.
13
+ *
14
+ * ```ts
15
+ * import { woocommerceConnector } from '@tallyui/connector-woocommerce';
16
+ * import { ConnectorProvider } from '@tallyui/core';
17
+ *
18
+ * <ConnectorProvider connector={woocommerceConnector}>
19
+ * <App />
20
+ * </ConnectorProvider>
21
+ * ```
22
+ */
23
+ export const woocommerceConnector: TallyConnector = {
24
+ id: 'woocommerce',
25
+ name: 'WooCommerce',
26
+ description: 'Connect to WooCommerce stores via the REST API',
27
+ icon: undefined, // TODO: WooCommerce logo
28
+
29
+ auth: {
30
+ type: 'WooCommerce REST API',
31
+ fields: [
32
+ {
33
+ key: 'url',
34
+ label: 'Store URL',
35
+ type: 'url',
36
+ placeholder: 'https://mystore.com',
37
+ required: true,
38
+ },
39
+ {
40
+ key: 'consumer_key',
41
+ label: 'Consumer Key',
42
+ type: 'text',
43
+ placeholder: 'ck_...',
44
+ required: true,
45
+ },
46
+ {
47
+ key: 'consumer_secret',
48
+ label: 'Consumer Secret',
49
+ type: 'password',
50
+ placeholder: 'cs_...',
51
+ required: true,
52
+ },
53
+ ],
54
+ getHeaders: (credentials) => {
55
+ const encoded = btoa(`${credentials.consumer_key}:${credentials.consumer_secret}`);
56
+ return {
57
+ Authorization: `Basic ${encoded}`,
58
+ };
59
+ },
60
+ },
61
+
62
+ schemas: {
63
+ products: wooProductSchema,
64
+ },
65
+
66
+ traits: {
67
+ product: wooProductTraits,
68
+ },
69
+
70
+ sync: {
71
+ products: wooProductSync,
72
+ },
73
+
74
+ replication: {
75
+ products: wooProductReplication,
76
+ },
77
+ };
78
+
79
+ // Re-export pieces for advanced usage
80
+ export { wooProductSchema } from './schemas/products';
81
+ export { wooProductTraits } from './traits/product';
82
+ export { wooProductSync } from './sync/products';
83
+ export { wooProductReplication } from './replication/products';
@@ -0,0 +1,189 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import type { SyncContext } from '@tallyui/core';
3
+
4
+ import { wooProductReplication } from './products';
5
+
6
+ const context: SyncContext = {
7
+ connectorId: 'woocommerce',
8
+ baseUrl: 'https://example.com/wp-json/wc/v3',
9
+ headers: { Authorization: 'Basic dGVzdDp0ZXN0' },
10
+ };
11
+
12
+ describe('wooProductReplication.pull.handler', () => {
13
+ beforeEach(() => {
14
+ vi.restoreAllMocks();
15
+ });
16
+
17
+ it('fetches products from initial checkpoint (undefined)', async () => {
18
+ const mockProducts = [
19
+ { id: 1, uuid: 'abc', name: 'Widget', date_modified_gmt: '2026-01-01T00:00:00' },
20
+ { id: 2, uuid: 'def', name: 'Gadget', date_modified_gmt: '2026-01-02T00:00:00' },
21
+ ];
22
+
23
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
24
+ new Response(JSON.stringify(mockProducts), { status: 200 }),
25
+ );
26
+
27
+ const result = await wooProductReplication.pull.handler(undefined, 100, context);
28
+
29
+ expect(result.documents).toHaveLength(2);
30
+ expect(result.documents[0]._deleted).toBe(false);
31
+ expect(result.checkpoint).toEqual({
32
+ id: 'def',
33
+ modified: '2026-01-02T00:00:00',
34
+ });
35
+ });
36
+
37
+ it('fetches products after a checkpoint', async () => {
38
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
39
+ new Response(JSON.stringify([]), { status: 200 }),
40
+ );
41
+
42
+ const checkpoint = { id: 'abc', modified: '2026-01-01T00:00:00' };
43
+ const result = await wooProductReplication.pull.handler(checkpoint, 100, context);
44
+
45
+ expect(result.documents).toHaveLength(0);
46
+
47
+ const calledUrl = (globalThis.fetch as any).mock.calls[0][0];
48
+ expect(calledUrl).toContain('modified_after=2026-01-01T00%3A00%3A00');
49
+ expect(calledUrl).toContain('orderby=modified');
50
+ expect(calledUrl).toContain('order=asc');
51
+ });
52
+
53
+ it('uses batchSize as per_page', async () => {
54
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
55
+ new Response(JSON.stringify([]), { status: 200 }),
56
+ );
57
+
58
+ await wooProductReplication.pull.handler(undefined, 25, context);
59
+
60
+ const calledUrl = (globalThis.fetch as any).mock.calls[0][0];
61
+ expect(calledUrl).toContain('per_page=25');
62
+ });
63
+
64
+ it('throws on non-OK response', async () => {
65
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
66
+ new Response('Internal Server Error', { status: 500 }),
67
+ );
68
+
69
+ await expect(
70
+ wooProductReplication.pull.handler(undefined, 100, context),
71
+ ).rejects.toThrow('WooCommerce API error: 500');
72
+ });
73
+
74
+ it('returns lastCheckpoint when no products are returned', async () => {
75
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
76
+ new Response(JSON.stringify([]), { status: 200 }),
77
+ );
78
+
79
+ const checkpoint = { id: 'abc', modified: '2026-01-01T00:00:00' };
80
+ const result = await wooProductReplication.pull.handler(checkpoint, 100, context);
81
+
82
+ expect(result.checkpoint).toEqual(checkpoint);
83
+ });
84
+
85
+ it('falls back to id when uuid is missing', async () => {
86
+ const mockProducts = [
87
+ { id: 42, name: 'No UUID', date_modified_gmt: '2026-03-01T00:00:00' },
88
+ ];
89
+
90
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
91
+ new Response(JSON.stringify(mockProducts), { status: 200 }),
92
+ );
93
+
94
+ const result = await wooProductReplication.pull.handler(undefined, 100, context);
95
+
96
+ expect(result.checkpoint.id).toBe('42');
97
+ });
98
+ });
99
+
100
+ describe('wooProductReplication.push.handler', () => {
101
+ beforeEach(() => {
102
+ vi.restoreAllMocks();
103
+ });
104
+
105
+ it('sends PUT for updates', async () => {
106
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
107
+ new Response(JSON.stringify({}), { status: 200 }),
108
+ );
109
+
110
+ const conflicts = await wooProductReplication.push!.handler(
111
+ [{
112
+ newDocumentState: { id: '1', name: 'Updated', _deleted: false } as any,
113
+ assumedMasterState: { id: '1', name: 'Old', _deleted: false } as any,
114
+ }],
115
+ context,
116
+ );
117
+
118
+ expect(conflicts).toHaveLength(0);
119
+ const [url, opts] = (globalThis.fetch as any).mock.calls[0];
120
+ expect(url).toContain('/products/1');
121
+ expect(opts.method).toBe('PUT');
122
+ });
123
+
124
+ it('sends POST for creates', async () => {
125
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
126
+ new Response(JSON.stringify({}), { status: 200 }),
127
+ );
128
+
129
+ const conflicts = await wooProductReplication.push!.handler(
130
+ [{
131
+ newDocumentState: { id: '1', name: 'New Product', _deleted: false } as any,
132
+ // No assumedMasterState = create
133
+ }],
134
+ context,
135
+ );
136
+
137
+ expect(conflicts).toHaveLength(0);
138
+ const [, opts] = (globalThis.fetch as any).mock.calls[0];
139
+ expect(opts.method).toBe('POST');
140
+ });
141
+
142
+ it('returns conflicts on server error', async () => {
143
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
144
+ new Response('conflict', { status: 409 }),
145
+ );
146
+
147
+ const conflicts = await wooProductReplication.push!.handler(
148
+ [{
149
+ newDocumentState: { id: '1', name: 'Updated', _deleted: false } as any,
150
+ assumedMasterState: { id: '1', name: 'Server Version', _deleted: false } as any,
151
+ }],
152
+ context,
153
+ );
154
+
155
+ expect(conflicts).toHaveLength(1);
156
+ expect(conflicts[0].name).toBe('Server Version');
157
+ });
158
+
159
+ it('returns conflicts on network failure', async () => {
160
+ vi.spyOn(globalThis, 'fetch').mockRejectedValueOnce(new Error('Network error'));
161
+
162
+ const conflicts = await wooProductReplication.push!.handler(
163
+ [{
164
+ newDocumentState: { id: '1', name: 'Updated', _deleted: false } as any,
165
+ assumedMasterState: { id: '1', name: 'Server Version', _deleted: false } as any,
166
+ }],
167
+ context,
168
+ );
169
+
170
+ expect(conflicts).toHaveLength(1);
171
+ });
172
+
173
+ it('does not return conflict for creates that fail', async () => {
174
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
175
+ new Response('error', { status: 500 }),
176
+ );
177
+
178
+ const conflicts = await wooProductReplication.push!.handler(
179
+ [{
180
+ newDocumentState: { id: '1', name: 'New Product', _deleted: false } as any,
181
+ // No assumedMasterState = create, so no conflict to report
182
+ }],
183
+ context,
184
+ );
185
+
186
+ // Creates without assumedMasterState don't produce conflicts
187
+ expect(conflicts).toHaveLength(0);
188
+ });
189
+ });
@@ -0,0 +1,93 @@
1
+ import type { ReplicationAdapter, SyncContext } from '@tallyui/core';
2
+
3
+ export type WooProductCheckpoint = {
4
+ id: string;
5
+ modified: string;
6
+ };
7
+
8
+ /**
9
+ * Replication adapter for WooCommerce products.
10
+ *
11
+ * Implements pull (cursor-based pagination by date_modified_gmt) and push
12
+ * (individual create/update via WC REST API v3). Designed for use with
13
+ * RxDB's replicateRxCollection.
14
+ */
15
+ export const wooProductReplication: ReplicationAdapter<any, WooProductCheckpoint> = {
16
+ pull: {
17
+ async handler(lastCheckpoint, batchSize, context) {
18
+ const params = new URLSearchParams({
19
+ per_page: String(batchSize),
20
+ orderby: 'modified',
21
+ order: 'asc',
22
+ });
23
+
24
+ if (lastCheckpoint?.modified) {
25
+ params.set('modified_after', lastCheckpoint.modified);
26
+ }
27
+
28
+ const response = await fetch(`${context.baseUrl}/products?${params}`, {
29
+ headers: {
30
+ ...context.headers,
31
+ 'Content-Type': 'application/json',
32
+ },
33
+ signal: context.signal,
34
+ });
35
+
36
+ if (!response.ok) {
37
+ throw new Error(`WooCommerce API error: ${response.status}`);
38
+ }
39
+
40
+ const products: any[] = await response.json();
41
+ const documents = products.map((p) => ({ ...p, _deleted: false }));
42
+
43
+ const checkpoint: WooProductCheckpoint = products.length > 0
44
+ ? {
45
+ id: String(products[products.length - 1].uuid ?? products[products.length - 1].id),
46
+ modified: products[products.length - 1].date_modified_gmt,
47
+ }
48
+ : lastCheckpoint ?? { id: '', modified: '' };
49
+
50
+ return { documents, checkpoint };
51
+ },
52
+ },
53
+
54
+ push: {
55
+ async handler(changeRows, context) {
56
+ const conflicts: any[] = [];
57
+
58
+ for (const row of changeRows) {
59
+ const doc = row.newDocumentState as any;
60
+
61
+ try {
62
+ const isCreate = !row.assumedMasterState;
63
+ const method = isCreate ? 'POST' : 'PUT';
64
+ const url = isCreate
65
+ ? `${context.baseUrl}/products`
66
+ : `${context.baseUrl}/products/${doc.id}`;
67
+
68
+ const response = await fetch(url, {
69
+ method,
70
+ headers: {
71
+ ...context.headers,
72
+ 'Content-Type': 'application/json',
73
+ },
74
+ body: JSON.stringify(doc),
75
+ signal: context.signal,
76
+ });
77
+
78
+ if (!response.ok) {
79
+ if (row.assumedMasterState) {
80
+ conflicts.push({ ...row.assumedMasterState, _deleted: false });
81
+ }
82
+ }
83
+ } catch {
84
+ if (row.assumedMasterState) {
85
+ conflicts.push({ ...row.assumedMasterState, _deleted: false });
86
+ }
87
+ }
88
+ }
89
+
90
+ return conflicts;
91
+ },
92
+ },
93
+ };
@@ -0,0 +1,150 @@
1
+ import type { RxJsonSchema } from 'rxdb';
2
+
3
+ /**
4
+ * WooCommerce Product RxDB schema.
5
+ * Closely mirrors the WooCommerce REST API product shape.
6
+ * Ported from @wcpos/database.
7
+ */
8
+ export const wooProductSchema: RxJsonSchema<any> = {
9
+ title: 'WooCommerce Product',
10
+ version: 0,
11
+ type: 'object',
12
+ primaryKey: 'uuid',
13
+ properties: {
14
+ uuid: {
15
+ type: 'string',
16
+ maxLength: 36,
17
+ },
18
+ id: {
19
+ type: 'integer',
20
+ multipleOf: 1,
21
+ minimum: 0,
22
+ maximum: 2147483647,
23
+ },
24
+ name: {
25
+ type: 'string',
26
+ },
27
+ slug: {
28
+ type: 'string',
29
+ },
30
+ type: {
31
+ type: 'string',
32
+ default: 'simple',
33
+ enum: ['simple', 'grouped', 'external', 'variable'],
34
+ },
35
+ status: {
36
+ type: 'string',
37
+ default: 'publish',
38
+ enum: ['draft', 'pending', 'private', 'publish'],
39
+ },
40
+ sku: {
41
+ type: 'string',
42
+ },
43
+ barcode: {
44
+ type: 'string',
45
+ maxLength: 255,
46
+ },
47
+ price: {
48
+ type: 'string',
49
+ },
50
+ regular_price: {
51
+ type: 'string',
52
+ },
53
+ sale_price: {
54
+ type: 'string',
55
+ },
56
+ on_sale: {
57
+ type: 'boolean',
58
+ },
59
+ description: {
60
+ type: 'string',
61
+ },
62
+ short_description: {
63
+ type: 'string',
64
+ },
65
+ stock_quantity: {
66
+ type: ['number', 'null'],
67
+ },
68
+ stock_status: {
69
+ type: 'string',
70
+ default: 'instock',
71
+ enum: ['instock', 'outofstock', 'onbackorder', 'lowstock'],
72
+ maxLength: 255,
73
+ },
74
+ manage_stock: {
75
+ type: 'boolean',
76
+ default: false,
77
+ },
78
+ categories: {
79
+ type: 'array',
80
+ items: {
81
+ type: 'object',
82
+ properties: {
83
+ id: { type: 'integer' },
84
+ name: { type: 'string' },
85
+ slug: { type: 'string' },
86
+ },
87
+ },
88
+ },
89
+ tags: {
90
+ type: 'array',
91
+ items: {
92
+ type: 'object',
93
+ properties: {
94
+ id: { type: 'integer' },
95
+ name: { type: 'string' },
96
+ slug: { type: 'string' },
97
+ },
98
+ },
99
+ },
100
+ images: {
101
+ type: 'array',
102
+ items: {
103
+ type: 'object',
104
+ properties: {
105
+ id: { type: 'integer' },
106
+ src: { type: 'string' },
107
+ name: { type: 'string' },
108
+ alt: { type: 'string' },
109
+ },
110
+ },
111
+ },
112
+ attributes: {
113
+ type: 'array',
114
+ items: {
115
+ type: 'object',
116
+ properties: {
117
+ id: { type: 'integer' },
118
+ name: { type: 'string' },
119
+ position: { type: 'integer' },
120
+ visible: { type: 'boolean' },
121
+ variation: { type: 'boolean' },
122
+ options: {
123
+ type: 'array',
124
+ items: { type: 'string' },
125
+ },
126
+ },
127
+ },
128
+ },
129
+ variations: {
130
+ type: 'array',
131
+ items: { type: 'integer' },
132
+ },
133
+ meta_data: {
134
+ type: 'array',
135
+ items: {
136
+ type: 'object',
137
+ properties: {
138
+ id: { type: 'integer' },
139
+ key: { type: 'string' },
140
+ value: { type: 'string' },
141
+ },
142
+ },
143
+ },
144
+ date_modified_gmt: {
145
+ type: 'string',
146
+ },
147
+ },
148
+ indexes: ['id', 'barcode', 'stock_status'],
149
+ required: ['uuid'],
150
+ };
@@ -0,0 +1,70 @@
1
+ import type { CollectionSync, SyncContext, RemoteIdEntry } from '@tallyui/core';
2
+
3
+ /**
4
+ * WooCommerce product sync implementation.
5
+ *
6
+ * Uses the WooCommerce REST API (or WCPOS API) to fetch products.
7
+ * For the MVP, this is a simple implementation that fetches all products.
8
+ * The full WCPOS sync engine has much more sophisticated diffing/queueing.
9
+ */
10
+ export const wooProductSync: CollectionSync = {
11
+ async fetchAllIds(context: SyncContext): Promise<RemoteIdEntry[]> {
12
+ const response = await fetch(`${context.baseUrl}/products`, {
13
+ headers: {
14
+ ...context.headers,
15
+ 'Content-Type': 'application/json',
16
+ },
17
+ signal: context.signal,
18
+ });
19
+
20
+ if (!response.ok) {
21
+ throw new Error(`WooCommerce API error: ${response.status}`);
22
+ }
23
+
24
+ const products = await response.json();
25
+ return products.map((p: any) => ({
26
+ id: String(p.id),
27
+ dateModified: p.date_modified_gmt,
28
+ }));
29
+ },
30
+
31
+ async fetchByIds(ids: string[], context: SyncContext): Promise<any[]> {
32
+ // WooCommerce supports ?include=1,2,3 for bulk fetching
33
+ const include = ids.join(',');
34
+ const response = await fetch(
35
+ `${context.baseUrl}/products?include=${include}&per_page=${ids.length}`,
36
+ {
37
+ headers: {
38
+ ...context.headers,
39
+ 'Content-Type': 'application/json',
40
+ },
41
+ signal: context.signal,
42
+ }
43
+ );
44
+
45
+ if (!response.ok) {
46
+ throw new Error(`WooCommerce API error: ${response.status}`);
47
+ }
48
+
49
+ return response.json();
50
+ },
51
+
52
+ async fetchModifiedAfter(date: string, context: SyncContext): Promise<any[]> {
53
+ const response = await fetch(
54
+ `${context.baseUrl}/products?modified_after=${date}&per_page=100`,
55
+ {
56
+ headers: {
57
+ ...context.headers,
58
+ 'Content-Type': 'application/json',
59
+ },
60
+ signal: context.signal,
61
+ }
62
+ );
63
+
64
+ if (!response.ok) {
65
+ throw new Error(`WooCommerce API error: ${response.status}`);
66
+ }
67
+
68
+ return response.json();
69
+ },
70
+ };
@@ -0,0 +1,48 @@
1
+ import type { ProductTraits } from '@tallyui/core';
2
+
3
+ /**
4
+ * WooCommerce product trait implementations.
5
+ *
6
+ * Maps the WooCommerce REST API product shape to the universal
7
+ * Tally UI product interface.
8
+ */
9
+ export const wooProductTraits: ProductTraits = {
10
+ getId: (doc) => String(doc.id),
11
+
12
+ getName: (doc) => doc.name ?? '',
13
+
14
+ getSku: (doc) => doc.sku || undefined,
15
+
16
+ getPrice: (doc) => doc.price || undefined,
17
+
18
+ getRegularPrice: (doc) => doc.regular_price || undefined,
19
+
20
+ getSalePrice: (doc) => doc.sale_price || undefined,
21
+
22
+ isOnSale: (doc) => doc.on_sale === true,
23
+
24
+ getImageUrl: (doc) => doc.images?.[0]?.src || undefined,
25
+
26
+ getImageUrls: (doc) => (doc.images ?? []).map((img: any) => img.src).filter(Boolean),
27
+
28
+ getDescription: (doc) => doc.short_description || doc.description || undefined,
29
+
30
+ getStockStatus: (doc) => {
31
+ const status = doc.stock_status;
32
+ if (status === 'instock' || status === 'outofstock' || status === 'onbackorder') {
33
+ return status;
34
+ }
35
+ return 'unknown';
36
+ },
37
+
38
+ getStockQuantity: (doc) => doc.stock_quantity ?? null,
39
+
40
+ hasVariants: (doc) => doc.type === 'variable',
41
+
42
+ getType: (doc) => doc.type ?? 'simple',
43
+
44
+ getBarcode: (doc) => doc.barcode || undefined,
45
+
46
+ getCategoryNames: (doc) =>
47
+ (doc.categories ?? []).map((cat: any) => cat.name).filter(Boolean),
48
+ };