@tallyui/connector-vendure 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,248 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { vendureProductTraits } from '../traits/product';
3
+
4
+ /**
5
+ * Realistic Vendure product document, shaped like the Admin GraphQL API response.
6
+ */
7
+ const fullProduct = {
8
+ id: '42',
9
+ name: 'Commercial Espresso Machine',
10
+ slug: 'commercial-espresso-machine',
11
+ description: '<p>High-end commercial espresso machine with dual boilers.</p>',
12
+ enabled: true,
13
+ featuredAsset: {
14
+ id: '89',
15
+ preview: 'https://cdn.example.com/assets/espresso-main__preview.jpg',
16
+ },
17
+ assets: [
18
+ { id: '89', preview: 'https://cdn.example.com/assets/espresso-main__preview.jpg' },
19
+ { id: '90', preview: 'https://cdn.example.com/assets/espresso-side__preview.jpg' },
20
+ { id: '91', preview: 'https://cdn.example.com/assets/espresso-back__preview.jpg' },
21
+ ],
22
+ collections: [
23
+ { id: '5', name: 'Equipment', slug: 'equipment' },
24
+ { id: '12', name: 'Coffee Machines', slug: 'coffee-machines' },
25
+ ],
26
+ facetValues: [
27
+ { id: '23', name: 'Espresso', code: 'espresso', facet: { id: '3', name: 'Category' } },
28
+ { id: '31', name: 'La Marzocco', code: 'la-marzocco', facet: { id: '5', name: 'Brand' } },
29
+ ],
30
+ variants: [
31
+ {
32
+ id: '101',
33
+ name: 'Commercial Espresso Machine 110V',
34
+ sku: 'VND-ESP-110',
35
+ price: 489900,
36
+ priceWithTax: 534191,
37
+ currencyCode: 'USD',
38
+ stockLevel: 'IN_STOCK',
39
+ stockOnHand: 12,
40
+ trackInventory: 'INHERIT',
41
+ featuredAsset: null,
42
+ options: [{ id: '14', name: '110V', code: '110v' }],
43
+ customFields: { barcode: '5901234123457' },
44
+ },
45
+ {
46
+ id: '102',
47
+ name: 'Commercial Espresso Machine 220V',
48
+ sku: 'VND-ESP-220',
49
+ price: 489900,
50
+ priceWithTax: 534191,
51
+ currencyCode: 'USD',
52
+ stockLevel: 'OUT_OF_STOCK',
53
+ stockOnHand: 0,
54
+ trackInventory: 'INHERIT',
55
+ featuredAsset: null,
56
+ options: [{ id: '15', name: '220V', code: '220v' }],
57
+ customFields: {},
58
+ },
59
+ ],
60
+ };
61
+
62
+ const simpleProduct = {
63
+ id: '43',
64
+ name: 'Coffee Mug',
65
+ slug: 'coffee-mug',
66
+ description: 'A simple ceramic mug.',
67
+ enabled: true,
68
+ featuredAsset: null,
69
+ assets: [],
70
+ collections: [],
71
+ facetValues: [],
72
+ variants: [
73
+ {
74
+ id: '201',
75
+ name: 'Coffee Mug',
76
+ sku: 'MUG-001',
77
+ price: 1299,
78
+ priceWithTax: 1499,
79
+ currencyCode: 'USD',
80
+ stockLevel: 'LOW_STOCK',
81
+ stockOnHand: 3,
82
+ trackInventory: 'INHERIT',
83
+ featuredAsset: null,
84
+ options: [],
85
+ customFields: {},
86
+ },
87
+ ],
88
+ };
89
+
90
+ const minimalProduct = {
91
+ id: '999',
92
+ };
93
+
94
+ describe('Vendure product traits', () => {
95
+ describe('getId', () => {
96
+ it('returns the product id as a string', () => {
97
+ expect(vendureProductTraits.getId(fullProduct)).toBe('42');
98
+ });
99
+ });
100
+
101
+ describe('getName', () => {
102
+ it('returns the name', () => {
103
+ expect(vendureProductTraits.getName(fullProduct)).toBe('Commercial Espresso Machine');
104
+ });
105
+
106
+ it('returns empty string for missing name', () => {
107
+ expect(vendureProductTraits.getName(minimalProduct)).toBe('');
108
+ });
109
+ });
110
+
111
+ describe('getSku', () => {
112
+ it('returns SKU from first variant', () => {
113
+ expect(vendureProductTraits.getSku(fullProduct)).toBe('VND-ESP-110');
114
+ });
115
+
116
+ it('returns undefined when no variants', () => {
117
+ expect(vendureProductTraits.getSku(minimalProduct)).toBeUndefined();
118
+ });
119
+ });
120
+
121
+ describe('getPrice', () => {
122
+ it('converts priceWithTax cents to decimal string', () => {
123
+ expect(vendureProductTraits.getPrice(fullProduct)).toBe('5341.91');
124
+ });
125
+
126
+ it('handles smaller prices', () => {
127
+ expect(vendureProductTraits.getPrice(simpleProduct)).toBe('14.99');
128
+ });
129
+
130
+ it('returns undefined when no variants', () => {
131
+ expect(vendureProductTraits.getPrice(minimalProduct)).toBeUndefined();
132
+ });
133
+ });
134
+
135
+ describe('getRegularPrice / getSalePrice / isOnSale', () => {
136
+ it('regular price matches price (Vendure uses promotions, not product-level sales)', () => {
137
+ expect(vendureProductTraits.getRegularPrice(fullProduct)).toBe('5341.91');
138
+ });
139
+
140
+ it('sale price is always undefined', () => {
141
+ expect(vendureProductTraits.getSalePrice(fullProduct)).toBeUndefined();
142
+ });
143
+
144
+ it('isOnSale is always false', () => {
145
+ expect(vendureProductTraits.isOnSale(fullProduct)).toBe(false);
146
+ });
147
+ });
148
+
149
+ describe('getImageUrl / getImageUrls', () => {
150
+ it('returns featuredAsset preview URL', () => {
151
+ expect(vendureProductTraits.getImageUrl(fullProduct)).toBe(
152
+ 'https://cdn.example.com/assets/espresso-main__preview.jpg'
153
+ );
154
+ });
155
+
156
+ it('returns all asset preview URLs', () => {
157
+ expect(vendureProductTraits.getImageUrls(fullProduct)).toEqual([
158
+ 'https://cdn.example.com/assets/espresso-main__preview.jpg',
159
+ 'https://cdn.example.com/assets/espresso-side__preview.jpg',
160
+ 'https://cdn.example.com/assets/espresso-back__preview.jpg',
161
+ ]);
162
+ });
163
+
164
+ it('returns undefined / empty when no assets', () => {
165
+ expect(vendureProductTraits.getImageUrl(simpleProduct)).toBeUndefined();
166
+ expect(vendureProductTraits.getImageUrls(simpleProduct)).toEqual([]);
167
+ });
168
+ });
169
+
170
+ describe('getDescription', () => {
171
+ it('returns the description', () => {
172
+ expect(vendureProductTraits.getDescription(fullProduct)).toBe(
173
+ '<p>High-end commercial espresso machine with dual boilers.</p>'
174
+ );
175
+ });
176
+
177
+ it('returns undefined when missing', () => {
178
+ expect(vendureProductTraits.getDescription(minimalProduct)).toBeUndefined();
179
+ });
180
+ });
181
+
182
+ describe('getStockStatus', () => {
183
+ it('maps IN_STOCK to instock', () => {
184
+ expect(vendureProductTraits.getStockStatus(fullProduct)).toBe('instock');
185
+ });
186
+
187
+ it('maps LOW_STOCK to instock', () => {
188
+ expect(vendureProductTraits.getStockStatus(simpleProduct)).toBe('instock');
189
+ });
190
+
191
+ it('maps OUT_OF_STOCK to outofstock', () => {
192
+ const outOfStock = {
193
+ ...fullProduct,
194
+ variants: [{ ...fullProduct.variants[1] }], // 220V variant is OUT_OF_STOCK
195
+ };
196
+ expect(vendureProductTraits.getStockStatus(outOfStock)).toBe('outofstock');
197
+ });
198
+
199
+ it('returns unknown when no variants', () => {
200
+ expect(vendureProductTraits.getStockStatus(minimalProduct)).toBe('unknown');
201
+ });
202
+ });
203
+
204
+ describe('getStockQuantity', () => {
205
+ it('returns stockOnHand from first variant', () => {
206
+ expect(vendureProductTraits.getStockQuantity(fullProduct)).toBe(12);
207
+ });
208
+
209
+ it('returns null when no variants', () => {
210
+ expect(vendureProductTraits.getStockQuantity(minimalProduct)).toBeNull();
211
+ });
212
+ });
213
+
214
+ describe('hasVariants / getType', () => {
215
+ it('multi-variant product has variants', () => {
216
+ expect(vendureProductTraits.hasVariants(fullProduct)).toBe(true);
217
+ expect(vendureProductTraits.getType(fullProduct)).toBe('variable');
218
+ });
219
+
220
+ it('single-variant product has no variants', () => {
221
+ expect(vendureProductTraits.hasVariants(simpleProduct)).toBe(false);
222
+ expect(vendureProductTraits.getType(simpleProduct)).toBe('simple');
223
+ });
224
+ });
225
+
226
+ describe('getBarcode', () => {
227
+ it('returns barcode from custom fields', () => {
228
+ expect(vendureProductTraits.getBarcode(fullProduct)).toBe('5901234123457');
229
+ });
230
+
231
+ it('returns undefined when no custom barcode', () => {
232
+ expect(vendureProductTraits.getBarcode(simpleProduct)).toBeUndefined();
233
+ });
234
+ });
235
+
236
+ describe('getCategoryNames', () => {
237
+ it('returns collection names', () => {
238
+ expect(vendureProductTraits.getCategoryNames(fullProduct)).toEqual([
239
+ 'Equipment',
240
+ 'Coffee Machines',
241
+ ]);
242
+ });
243
+
244
+ it('returns empty array when no collections', () => {
245
+ expect(vendureProductTraits.getCategoryNames(simpleProduct)).toEqual([]);
246
+ });
247
+ });
248
+ });
package/src/index.ts ADDED
@@ -0,0 +1,73 @@
1
+ import type { TallyConnector } from '@tallyui/core';
2
+
3
+ import { vendureProductSchema } from './schemas/products';
4
+ import { vendureProductTraits } from './traits/product';
5
+ import { vendureProductSync } from './sync/products';
6
+ import { vendureProductReplication } from './replication/products';
7
+
8
+ /**
9
+ * Vendure connector for Tally UI.
10
+ *
11
+ * Connects to Vendure backends via the Admin GraphQL API.
12
+ * Products are stored in RxDB using a schema that mirrors the Vendure API shape.
13
+ *
14
+ * ```ts
15
+ * import { vendureConnector } from '@tallyui/connector-vendure';
16
+ * import { ConnectorProvider } from '@tallyui/core';
17
+ *
18
+ * <ConnectorProvider connector={vendureConnector}>
19
+ * <App />
20
+ * </ConnectorProvider>
21
+ * ```
22
+ */
23
+ export const vendureConnector: TallyConnector = {
24
+ id: 'vendure',
25
+ name: 'Vendure',
26
+ description: 'Connect to Vendure backends via the Admin GraphQL API',
27
+ icon: undefined,
28
+
29
+ auth: {
30
+ type: 'Vendure Admin API',
31
+ fields: [
32
+ {
33
+ key: 'url',
34
+ label: 'Backend URL',
35
+ type: 'url',
36
+ placeholder: 'https://my-vendure-server.com',
37
+ required: true,
38
+ },
39
+ {
40
+ key: 'auth_token',
41
+ label: 'Auth Token',
42
+ type: 'password',
43
+ placeholder: 'vendure-auth-token from login',
44
+ required: true,
45
+ },
46
+ ],
47
+ getHeaders: (credentials) => ({
48
+ Authorization: `Bearer ${credentials.auth_token}`,
49
+ }),
50
+ },
51
+
52
+ schemas: {
53
+ products: vendureProductSchema,
54
+ },
55
+
56
+ traits: {
57
+ product: vendureProductTraits,
58
+ },
59
+
60
+ sync: {
61
+ products: vendureProductSync,
62
+ },
63
+
64
+ replication: {
65
+ products: vendureProductReplication,
66
+ },
67
+ };
68
+
69
+ // Re-export pieces for advanced usage
70
+ export { vendureProductSchema } from './schemas/products';
71
+ export { vendureProductTraits } from './traits/product';
72
+ export { vendureProductSync } from './sync/products';
73
+ export { vendureProductReplication } from './replication/products';
@@ -0,0 +1,204 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import type { SyncContext } from '@tallyui/core';
3
+
4
+ import { vendureProductReplication } from './products';
5
+
6
+ const context: SyncContext = {
7
+ connectorId: 'vendure',
8
+ baseUrl: 'https://my-vendure-server.com',
9
+ headers: { Authorization: 'Bearer test_token' },
10
+ };
11
+
12
+ /**
13
+ * Helper to build a mock GraphQL response.
14
+ */
15
+ function gqlResponse(data: any, errors?: any[]) {
16
+ return new Response(
17
+ JSON.stringify({ data, errors }),
18
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
19
+ );
20
+ }
21
+
22
+ describe('vendureProductReplication.pull.handler', () => {
23
+ beforeEach(() => {
24
+ vi.restoreAllMocks();
25
+ });
26
+
27
+ it('fetches products from initial checkpoint (undefined)', async () => {
28
+ const mockProducts = [
29
+ { id: '1', name: 'Widget', updatedAt: '2026-01-01T00:00:00Z' },
30
+ { id: '2', name: 'Gadget', updatedAt: '2026-01-02T00:00:00Z' },
31
+ ];
32
+
33
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
34
+ gqlResponse({
35
+ products: { items: mockProducts, totalItems: 2 },
36
+ }),
37
+ );
38
+
39
+ const result = await vendureProductReplication.pull.handler(undefined, 100, context);
40
+
41
+ expect(result.documents).toHaveLength(2);
42
+ expect(result.documents[0]._deleted).toBe(false);
43
+ expect(result.checkpoint).toEqual({
44
+ skip: 0,
45
+ updatedAt: '2026-01-02T00:00:00Z',
46
+ });
47
+
48
+ // Verify the GraphQL request
49
+ const [url, opts] = (globalThis.fetch as any).mock.calls[0];
50
+ expect(url).toContain('/admin-api');
51
+ const body = JSON.parse(opts.body);
52
+ expect(body.variables.options.take).toBe(100);
53
+ expect(body.variables.options.skip).toBe(0);
54
+ expect(body.variables.options.filter).toBeUndefined();
55
+ });
56
+
57
+ it('passes updatedAt filter and skip from checkpoint', async () => {
58
+ const mockProducts = [
59
+ { id: '3', name: 'Thingamajig', updatedAt: '2026-02-01T00:00:00Z' },
60
+ ];
61
+
62
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
63
+ gqlResponse({
64
+ products: { items: mockProducts, totalItems: 1 },
65
+ }),
66
+ );
67
+
68
+ const checkpoint = { skip: 50, updatedAt: '2026-01-15T00:00:00Z' };
69
+ const result = await vendureProductReplication.pull.handler(checkpoint, 100, context);
70
+
71
+ const body = JSON.parse((globalThis.fetch as any).mock.calls[0][1].body);
72
+ expect(body.variables.options.skip).toBe(50);
73
+ expect(body.variables.options.filter).toEqual({
74
+ updatedAt: { after: '2026-01-15T00:00:00Z' },
75
+ });
76
+
77
+ // Batch smaller than batchSize resets skip to 0
78
+ expect(result.checkpoint.skip).toBe(0);
79
+ });
80
+
81
+ it('advances skip when batch equals batchSize', async () => {
82
+ const mockProducts = Array.from({ length: 25 }, (_, i) => ({
83
+ id: String(i),
84
+ name: `Product ${i}`,
85
+ updatedAt: '2026-01-01T00:00:00Z',
86
+ }));
87
+
88
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
89
+ gqlResponse({
90
+ products: { items: mockProducts, totalItems: 100 },
91
+ }),
92
+ );
93
+
94
+ const result = await vendureProductReplication.pull.handler(undefined, 25, context);
95
+
96
+ expect(result.checkpoint.skip).toBe(25);
97
+ });
98
+
99
+ it('throws on non-OK HTTP response', async () => {
100
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
101
+ new Response('Internal Server Error', { status: 500 }),
102
+ );
103
+
104
+ await expect(
105
+ vendureProductReplication.pull.handler(undefined, 100, context),
106
+ ).rejects.toThrow('Vendure API error: 500');
107
+ });
108
+
109
+ it('throws on GraphQL errors', async () => {
110
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
111
+ gqlResponse(null, [{ message: 'Something went wrong' }]),
112
+ );
113
+
114
+ await expect(
115
+ vendureProductReplication.pull.handler(undefined, 100, context),
116
+ ).rejects.toThrow('Vendure GraphQL error: Something went wrong');
117
+ });
118
+
119
+ it('returns lastCheckpoint when no products are returned', async () => {
120
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
121
+ gqlResponse({
122
+ products: { items: [], totalItems: 0 },
123
+ }),
124
+ );
125
+
126
+ const checkpoint = { skip: 0, updatedAt: '2026-01-01T00:00:00Z' };
127
+ const result = await vendureProductReplication.pull.handler(checkpoint, 100, context);
128
+
129
+ expect(result.checkpoint).toEqual(checkpoint);
130
+ });
131
+ });
132
+
133
+ describe('vendureProductReplication.push.handler', () => {
134
+ beforeEach(() => {
135
+ vi.restoreAllMocks();
136
+ });
137
+
138
+ it('sends updateProduct mutation', async () => {
139
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
140
+ gqlResponse({
141
+ updateProduct: { id: '1', updatedAt: '2026-01-03T00:00:00Z' },
142
+ }),
143
+ );
144
+
145
+ const conflicts = await vendureProductReplication.push!.handler(
146
+ [{
147
+ newDocumentState: { id: '1', name: 'Updated', _deleted: false } as any,
148
+ assumedMasterState: { id: '1', name: 'Old', _deleted: false } as any,
149
+ }],
150
+ context,
151
+ );
152
+
153
+ expect(conflicts).toHaveLength(0);
154
+ const body = JSON.parse((globalThis.fetch as any).mock.calls[0][1].body);
155
+ expect(body.query).toContain('updateProduct');
156
+ expect(body.variables.input.name).toBe('Updated');
157
+ });
158
+
159
+ it('returns conflicts on GraphQL error', async () => {
160
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
161
+ gqlResponse(null, [{ message: 'Conflict' }]),
162
+ );
163
+
164
+ const conflicts = await vendureProductReplication.push!.handler(
165
+ [{
166
+ newDocumentState: { id: '1', name: 'Updated', _deleted: false } as any,
167
+ assumedMasterState: { id: '1', name: 'Server Version', _deleted: false } as any,
168
+ }],
169
+ context,
170
+ );
171
+
172
+ expect(conflicts).toHaveLength(1);
173
+ expect(conflicts[0].name).toBe('Server Version');
174
+ });
175
+
176
+ it('returns conflicts on network failure', async () => {
177
+ vi.spyOn(globalThis, 'fetch').mockRejectedValueOnce(new Error('Network error'));
178
+
179
+ const conflicts = await vendureProductReplication.push!.handler(
180
+ [{
181
+ newDocumentState: { id: '1', name: 'Updated', _deleted: false } as any,
182
+ assumedMasterState: { id: '1', name: 'Server Version', _deleted: false } as any,
183
+ }],
184
+ context,
185
+ );
186
+
187
+ expect(conflicts).toHaveLength(1);
188
+ });
189
+
190
+ it('does not return conflict when assumedMasterState is absent', async () => {
191
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
192
+ gqlResponse(null, [{ message: 'Error' }]),
193
+ );
194
+
195
+ const conflicts = await vendureProductReplication.push!.handler(
196
+ [{
197
+ newDocumentState: { id: '1', name: 'New Product', _deleted: false } as any,
198
+ }],
199
+ context,
200
+ );
201
+
202
+ expect(conflicts).toHaveLength(0);
203
+ });
204
+ });
@@ -0,0 +1,148 @@
1
+ import type { ReplicationAdapter, SyncContext } from '@tallyui/core';
2
+
3
+ export type VendureProductCheckpoint = {
4
+ skip: number;
5
+ updatedAt: string;
6
+ };
7
+
8
+ const PRODUCT_LIST_QUERY = `
9
+ query GetProducts($options: ProductListOptions) {
10
+ products(options: $options) {
11
+ items {
12
+ id
13
+ createdAt
14
+ updatedAt
15
+ name
16
+ slug
17
+ description
18
+ enabled
19
+ featuredAsset { id preview }
20
+ assets { id preview }
21
+ collections { id name slug }
22
+ facetValues { id name code facet { id name } }
23
+ variants {
24
+ id
25
+ name
26
+ sku
27
+ price
28
+ priceWithTax
29
+ currencyCode
30
+ stockLevel
31
+ stockOnHand
32
+ trackInventory
33
+ featuredAsset { id preview }
34
+ options { id name code }
35
+ customFields
36
+ }
37
+ }
38
+ totalItems
39
+ }
40
+ }
41
+ `;
42
+
43
+ const UPDATE_PRODUCT_MUTATION = `
44
+ mutation UpdateProduct($input: UpdateProductInput!) {
45
+ updateProduct(input: $input) {
46
+ id
47
+ updatedAt
48
+ }
49
+ }
50
+ `;
51
+
52
+ /**
53
+ * Helper to execute a GraphQL query against the Vendure Admin API.
54
+ */
55
+ async function gql(
56
+ context: SyncContext,
57
+ query: string,
58
+ variables?: Record<string, any>,
59
+ ): Promise<any> {
60
+ const res = await fetch(`${context.baseUrl}/admin-api`, {
61
+ method: 'POST',
62
+ headers: {
63
+ 'Content-Type': 'application/json',
64
+ ...context.headers,
65
+ },
66
+ body: JSON.stringify({ query, variables }),
67
+ signal: context.signal,
68
+ });
69
+
70
+ if (!res.ok) throw new Error(`Vendure API error: ${res.status}`);
71
+ return res.json();
72
+ }
73
+
74
+ /**
75
+ * Replication adapter for Vendure products.
76
+ *
77
+ * Implements pull (GraphQL query with offset pagination and updatedAt
78
+ * filtering) and push (GraphQL updateProduct mutation). Designed for
79
+ * use with RxDB's replicateRxCollection.
80
+ */
81
+ export const vendureProductReplication: ReplicationAdapter<any, VendureProductCheckpoint> = {
82
+ pull: {
83
+ async handler(lastCheckpoint, batchSize, context) {
84
+ const options: Record<string, any> = {
85
+ take: batchSize,
86
+ skip: lastCheckpoint?.skip ?? 0,
87
+ };
88
+
89
+ if (lastCheckpoint?.updatedAt) {
90
+ options.filter = {
91
+ updatedAt: { after: lastCheckpoint.updatedAt },
92
+ };
93
+ }
94
+
95
+ const res = await gql(context, PRODUCT_LIST_QUERY, { options });
96
+
97
+ if (res.errors?.length) {
98
+ throw new Error(`Vendure GraphQL error: ${res.errors[0].message}`);
99
+ }
100
+
101
+ const data = res.data?.products;
102
+ const products: any[] = data?.items ?? [];
103
+ const documents = products.map((p) => ({ ...p, _deleted: false }));
104
+
105
+ // Reset skip when batch is smaller than batchSize (end of page)
106
+ const nextSkip = products.length < batchSize
107
+ ? 0
108
+ : (lastCheckpoint?.skip ?? 0) + products.length;
109
+
110
+ const checkpoint: VendureProductCheckpoint = products.length > 0
111
+ ? {
112
+ skip: nextSkip,
113
+ updatedAt: products[products.length - 1].updatedAt,
114
+ }
115
+ : lastCheckpoint ?? { skip: 0, updatedAt: '' };
116
+
117
+ return { documents, checkpoint };
118
+ },
119
+ },
120
+
121
+ push: {
122
+ async handler(changeRows, context) {
123
+ const conflicts: any[] = [];
124
+
125
+ for (const row of changeRows) {
126
+ const doc = row.newDocumentState as any;
127
+
128
+ try {
129
+ const res = await gql(context, UPDATE_PRODUCT_MUTATION, {
130
+ input: doc,
131
+ });
132
+
133
+ if (res.errors?.length) {
134
+ if (row.assumedMasterState) {
135
+ conflicts.push({ ...row.assumedMasterState, _deleted: false });
136
+ }
137
+ }
138
+ } catch {
139
+ if (row.assumedMasterState) {
140
+ conflicts.push({ ...row.assumedMasterState, _deleted: false });
141
+ }
142
+ }
143
+ }
144
+
145
+ return conflicts;
146
+ },
147
+ },
148
+ };