@tallyui/connector-vendure 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 +7 -0
- package/dist/index.d.ts +100 -28
- package/dist/index.js +661 -145
- package/package.json +13 -4
- package/src/auth.ts +79 -0
- package/src/capabilities.ts +44 -0
- package/src/global-settings.ts +18 -0
- package/src/index.ts +86 -47
- package/src/reconcile/ids.ts +38 -0
- package/src/reconcile/prices.ts +58 -0
- package/src/reconcile/stock.ts +48 -0
- package/src/replication/products.ts +162 -62
- package/src/replication/variant-feed.ts +108 -0
- package/src/schemas/products.ts +19 -1
- package/src/session-probe.ts +39 -0
- package/src/store-settings.ts +81 -0
- package/src/sync/products.ts +16 -9
- package/src/traits/product.ts +123 -23
- package/src/__tests__/product-traits.test.ts +0 -248
- package/src/replication/products.test.ts +0 -204
|
@@ -1,11 +1,17 @@
|
|
|
1
|
+
import { compareIds, ConnectorUnauthorizedError } from '@tallyui/core';
|
|
1
2
|
import type { ReplicationAdapter, SyncContext } from '@tallyui/core';
|
|
3
|
+
import { vendureProductSchema } from '../schemas/products';
|
|
4
|
+
import { forbiddenError } from '../session-probe';
|
|
2
5
|
|
|
3
6
|
export type VendureProductCheckpoint = {
|
|
4
7
|
skip: number;
|
|
5
8
|
updatedAt: string;
|
|
9
|
+
passMax?: string;
|
|
10
|
+
passHighWater?: string;
|
|
11
|
+
passTotal?: number;
|
|
6
12
|
};
|
|
7
13
|
|
|
8
|
-
const PRODUCT_LIST_QUERY = `
|
|
14
|
+
export const PRODUCT_LIST_QUERY = (barcodeField?: string) => `
|
|
9
15
|
query GetProducts($options: ProductListOptions) {
|
|
10
16
|
products(options: $options) {
|
|
11
17
|
items {
|
|
@@ -27,12 +33,15 @@ const PRODUCT_LIST_QUERY = `
|
|
|
27
33
|
price
|
|
28
34
|
priceWithTax
|
|
29
35
|
currencyCode
|
|
30
|
-
|
|
31
|
-
stockOnHand
|
|
36
|
+
stockLevels { stockLocationId stockOnHand stockAllocated }
|
|
32
37
|
trackInventory
|
|
38
|
+
outOfStockThreshold
|
|
39
|
+
useGlobalOutOfStockThreshold
|
|
40
|
+
enabled
|
|
33
41
|
featuredAsset { id preview }
|
|
34
42
|
options { id name code }
|
|
35
|
-
|
|
43
|
+
taxCategory { id }
|
|
44
|
+
${barcodeField ? `customFields { ${barcodeField} }` : ''}
|
|
36
45
|
}
|
|
37
46
|
}
|
|
38
47
|
totalItems
|
|
@@ -40,11 +49,12 @@ const PRODUCT_LIST_QUERY = `
|
|
|
40
49
|
}
|
|
41
50
|
`;
|
|
42
51
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
updatedAt
|
|
52
|
+
/** The high-water mark and the skew probes only compare ids and timestamps (backlog 29). */
|
|
53
|
+
const PRODUCT_MARK_QUERY = `
|
|
54
|
+
query GetProductsMark($options: ProductListOptions) {
|
|
55
|
+
products(options: $options) {
|
|
56
|
+
items { id updatedAt }
|
|
57
|
+
totalItems
|
|
48
58
|
}
|
|
49
59
|
}
|
|
50
60
|
`;
|
|
@@ -52,11 +62,12 @@ const UPDATE_PRODUCT_MUTATION = `
|
|
|
52
62
|
/**
|
|
53
63
|
* Helper to execute a GraphQL query against the Vendure Admin API.
|
|
54
64
|
*/
|
|
55
|
-
async function gql(
|
|
65
|
+
export async function gql(
|
|
56
66
|
context: SyncContext,
|
|
57
67
|
query: string,
|
|
58
68
|
variables?: Record<string, any>,
|
|
59
69
|
): Promise<any> {
|
|
70
|
+
type GqlBody = { data?: any; errors?: Array<{ message?: string; path?: Array<string | number>; extensions?: { code?: string } }> };
|
|
60
71
|
const res = await fetch(`${context.baseUrl}/admin-api`, {
|
|
61
72
|
method: 'POST',
|
|
62
73
|
headers: {
|
|
@@ -67,82 +78,171 @@ async function gql(
|
|
|
67
78
|
signal: context.signal,
|
|
68
79
|
});
|
|
69
80
|
|
|
70
|
-
if (!res.ok)
|
|
71
|
-
|
|
81
|
+
if (!res.ok) {
|
|
82
|
+
const body = await res.json().catch(() => undefined) as GqlBody | undefined;
|
|
83
|
+
const message = body?.errors?.[0]?.message;
|
|
84
|
+
if (res.status === 401 || res.status === 403) throw new ConnectorUnauthorizedError(`Vendure API error: ${res.status}${message ? `: ${message}` : ''}`, res.status);
|
|
85
|
+
throw new Error(`Vendure API error: ${res.status}${message ? `: ${message}` : ''}`);
|
|
86
|
+
}
|
|
87
|
+
const body = await res.json() as GqlBody;
|
|
88
|
+
const forbidden = body.errors?.filter((error) => error.extensions?.code === 'FORBIDDEN') ?? [];
|
|
89
|
+
if (forbidden.length) throw await forbiddenError(context, forbidden);
|
|
90
|
+
if (body.errors?.length) throw new Error(`Vendure GraphQL error: ${body.errors[0].message}`);
|
|
91
|
+
return body;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const TIMEZONE_MESSAGE = 'Vendure updatedAt filters miss changes: run Vendure with TZ=UTC or set updatedAtSkewMs to at least the magnitude of the server UTC offset in milliseconds.';
|
|
95
|
+
|
|
96
|
+
/** The server's updatedAt filters miss changes because it does not run in UTC (probeUpdatedAtSkew). */
|
|
97
|
+
export class VendureTimezoneConfigError extends Error {
|
|
98
|
+
name = 'VendureTimezoneConfigError';
|
|
99
|
+
readonly code = 'store_misconfigured' as const;
|
|
100
|
+
/** Only the store owner can fix it on the server, so the pull waits the store delay (`errorKind`). */
|
|
101
|
+
readonly fixedBy = 'store' as const;
|
|
102
|
+
/**
|
|
103
|
+
* The store-side remedy, for `SyncStatus`'s detail. The message's other remedy,
|
|
104
|
+
* `updatedAtSkewMs`, is a connector option on the till, so it stays in the message only.
|
|
105
|
+
*/
|
|
106
|
+
readonly fix = 'run the Vendure server with its time zone set to UTC';
|
|
107
|
+
|
|
108
|
+
constructor() {
|
|
109
|
+
super(TIMEZONE_MESSAGE);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Probes around a pass's high-water mark for the server's updatedAt skew
|
|
115
|
+
* (ADR-060). `fetchTotal(after)` runs the caller's own list query (products,
|
|
116
|
+
* variants) and returns its totalItems. Returns the mark actually probed
|
|
117
|
+
* (backlog 31: `remark`, given, may re-read a newer one; see below).
|
|
118
|
+
*/
|
|
119
|
+
export async function probeUpdatedAtSkew(
|
|
120
|
+
passHighWater: string,
|
|
121
|
+
updatedAtSkewMs: number,
|
|
122
|
+
fetchTotal: (after: string) => Promise<number>,
|
|
123
|
+
remark?: () => Promise<string | undefined>,
|
|
124
|
+
): Promise<string> {
|
|
125
|
+
for (const overlap of [1, 0]) {
|
|
126
|
+
if (overlap === 0 && updatedAtSkewMs > 0) continue;
|
|
127
|
+
const after = (mark: string) => new Date(Date.parse(mark) + (overlap === 0 ? 1 : -1) - updatedAtSkewMs).toISOString();
|
|
128
|
+
let total = await fetchTotal(after(passHighWater));
|
|
129
|
+
if (overlap === 1 && total === 0 && remark) {
|
|
130
|
+
// The mark's own product can be deleted between the mark read and this
|
|
131
|
+
// probe (harmless on its own: RxDB retries the resulting error and the
|
|
132
|
+
// next mark read picks a survivor). Re-reading the mark once here means
|
|
133
|
+
// that common case settles without throwing at all.
|
|
134
|
+
const remarked = await remark();
|
|
135
|
+
if (remarked) {
|
|
136
|
+
passHighWater = remarked;
|
|
137
|
+
total = await fetchTotal(after(passHighWater));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (overlap === 1 && total === 0) {
|
|
141
|
+
// Only a re-read rules out a mark deleted mid-probe; without one (the variant feed) the race stays a transient error.
|
|
142
|
+
if (remark) throw new VendureTimezoneConfigError();
|
|
143
|
+
throw new Error(TIMEZONE_MESSAGE);
|
|
144
|
+
}
|
|
145
|
+
if (overlap === 0 && total > 0) {
|
|
146
|
+
console.warn('Vendure updatedAt filters over-fetch because the server is not in UTC.');
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return passHighWater;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Project an API product onto the schema's top-level fields (RxDB rejects undeclared ones). Sorts `variants` by id (a stable copy; the input is not mutated), since Vendure does not guarantee variant order. */
|
|
153
|
+
export function toProductDocument(p: any): Record<string, unknown> {
|
|
154
|
+
const doc: Record<string, unknown> = { _deleted: false };
|
|
155
|
+
for (const field of Object.keys(vendureProductSchema.properties)) {
|
|
156
|
+
if (p[field] !== undefined) doc[field] = p[field];
|
|
157
|
+
}
|
|
158
|
+
if (Array.isArray(doc.variants)) {
|
|
159
|
+
doc.variants = [...doc.variants].sort((a: any, b: any) => compareIds(a.id, b.id));
|
|
160
|
+
}
|
|
161
|
+
return doc;
|
|
72
162
|
}
|
|
73
163
|
|
|
74
164
|
/**
|
|
75
165
|
* Replication adapter for Vendure products.
|
|
76
166
|
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
167
|
+
* Pull-only (GraphQL query with offset pagination and updatedAt filtering)
|
|
168
|
+
* for RxDB's replicateRxCollection. Catalogue data is server-owned; the
|
|
169
|
+
* POS never writes products.
|
|
80
170
|
*/
|
|
81
|
-
export const
|
|
171
|
+
export const createVendureProductReplication = (barcodeField?: string, updatedAtSkewMs = 0): ReplicationAdapter<any, VendureProductCheckpoint> => ({
|
|
82
172
|
pull: {
|
|
83
173
|
async handler(lastCheckpoint, batchSize, context) {
|
|
174
|
+
if (batchSize > 1000) throw new Error('Vendure Admin API take must not exceed 1000');
|
|
175
|
+
// A checkpoint with skip > 0 but neither passHighWater nor passTotal is
|
|
176
|
+
// the shape saved mid-pass by the code before #45 gave a pass its own
|
|
177
|
+
// high-water mark and total (that code kept only skip and updatedAt).
|
|
178
|
+
// Its skip counts offset pages under a different sort and a moving
|
|
179
|
+
// updatedAt filter, which don't line up with this pull's fixed-window
|
|
180
|
+
// paging by id: resuming at that offset could skip rows. Restarting the
|
|
181
|
+
// pass from offset 0 cannot skip anything, only re-read rows this or an
|
|
182
|
+
// earlier pass already applied, which RxDB's upsert makes harmless.
|
|
183
|
+
if (lastCheckpoint?.skip && lastCheckpoint.passHighWater === undefined && lastCheckpoint.passTotal === undefined) {
|
|
184
|
+
lastCheckpoint = { skip: 0, updatedAt: lastCheckpoint.updatedAt ?? '' };
|
|
185
|
+
}
|
|
186
|
+
let passHighWater = lastCheckpoint?.passHighWater ?? lastCheckpoint?.updatedAt ?? '';
|
|
187
|
+
if (!lastCheckpoint?.skip) {
|
|
188
|
+
const fetchMark = async () => {
|
|
189
|
+
const head = await gql(context, PRODUCT_MARK_QUERY, { options: { take: 1, sort: { updatedAt: 'DESC' } } });
|
|
190
|
+
return head.data?.products?.items?.[0]?.updatedAt as string | undefined;
|
|
191
|
+
};
|
|
192
|
+
const mark = await fetchMark();
|
|
193
|
+
passHighWater = mark ?? lastCheckpoint?.updatedAt ?? '';
|
|
194
|
+
// Same-ms writes after this read wait for a newer mark or periodic reconcile (ADR-060, to come).
|
|
195
|
+
if (mark === undefined || (lastCheckpoint?.updatedAt && passHighWater === lastCheckpoint.updatedAt)) {
|
|
196
|
+
return { documents: [], checkpoint: lastCheckpoint ?? { skip: 0, updatedAt: '' } };
|
|
197
|
+
}
|
|
198
|
+
passHighWater = await probeUpdatedAtSkew(passHighWater, updatedAtSkewMs, async (after) => {
|
|
199
|
+
const probe = await gql(context, PRODUCT_MARK_QUERY, {
|
|
200
|
+
options: { take: 1, sort: { updatedAt: 'DESC' }, filter: { updatedAt: { after } } },
|
|
201
|
+
});
|
|
202
|
+
return probe.data.products.totalItems;
|
|
203
|
+
}, fetchMark);
|
|
204
|
+
}
|
|
84
205
|
const options: Record<string, any> = {
|
|
85
206
|
take: batchSize,
|
|
86
207
|
skip: lastCheckpoint?.skip ?? 0,
|
|
208
|
+
sort: { id: 'ASC' },
|
|
87
209
|
};
|
|
88
210
|
|
|
89
211
|
if (lastCheckpoint?.updatedAt) {
|
|
90
212
|
options.filter = {
|
|
91
|
-
|
|
213
|
+
// Vendure after is strict; overlap by 1 ms to include timestamp ties.
|
|
214
|
+
updatedAt: { after: new Date(Date.parse(lastCheckpoint.updatedAt) - 1 - updatedAtSkewMs).toISOString() },
|
|
92
215
|
};
|
|
93
216
|
}
|
|
94
217
|
|
|
95
|
-
const res = await gql(context, PRODUCT_LIST_QUERY, { options });
|
|
218
|
+
const res = await gql(context, PRODUCT_LIST_QUERY(barcodeField), { options });
|
|
96
219
|
|
|
97
|
-
|
|
98
|
-
|
|
220
|
+
let data = res.data?.products;
|
|
221
|
+
let passTotal = options.skip === 0 ? data.totalItems : lastCheckpoint?.passTotal ?? data.totalItems;
|
|
222
|
+
if (options.skip > 0 && data.totalItems < passTotal) {
|
|
223
|
+
options.skip = 0;
|
|
224
|
+
const restarted = await gql(context, PRODUCT_LIST_QUERY(barcodeField), { options });
|
|
225
|
+
data = restarted.data?.products;
|
|
226
|
+
passTotal = data.totalItems;
|
|
99
227
|
}
|
|
100
|
-
|
|
101
|
-
const data = res.data?.products;
|
|
102
228
|
const products: any[] = data?.items ?? [];
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
229
|
+
if (!products.length && options.skip > 0) {
|
|
230
|
+
return createVendureProductReplication(barcodeField, updatedAtSkewMs).pull.handler(
|
|
231
|
+
{ skip: 0, updatedAt: lastCheckpoint?.updatedAt ?? '' }, batchSize, context,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
const documents = products.map(toProductDocument);
|
|
109
235
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
236
|
+
// Keep the lower bound fixed while paging by id; advance it only at pass end.
|
|
237
|
+
// RxDB merges checkpoints, so explicitly clear pass state at completion.
|
|
238
|
+
const checkpoint: VendureProductCheckpoint = options.skip + products.length >= data.totalItems
|
|
239
|
+
? { skip: 0, updatedAt: passHighWater, passHighWater: undefined, passTotal: undefined }
|
|
240
|
+
: { skip: options.skip + products.length,
|
|
241
|
+
updatedAt: lastCheckpoint?.updatedAt ?? '', passHighWater, passTotal };
|
|
116
242
|
|
|
117
243
|
return { documents, checkpoint };
|
|
118
244
|
},
|
|
119
245
|
},
|
|
246
|
+
});
|
|
120
247
|
|
|
121
|
-
|
|
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
|
-
};
|
|
248
|
+
export const vendureProductReplication = createVendureProductReplication();
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { ReplicationAdapter, SyncContext } from '@tallyui/core';
|
|
2
|
+
import { gql, PRODUCT_LIST_QUERY, probeUpdatedAtSkew, toProductDocument, type VendureProductCheckpoint } from './products';
|
|
3
|
+
|
|
4
|
+
/** Vendure Admin API maximum `take`; variant pages and parent re-fetch chunks use it. */
|
|
5
|
+
const MAX_TAKE = 1000;
|
|
6
|
+
|
|
7
|
+
const variants = (context: SyncContext, fields: string, options: Record<string, any>) => gql(context, `
|
|
8
|
+
query GetVariants($options: ProductVariantListOptions) {
|
|
9
|
+
productVariants(options: $options) { items { ${fields} } totalItems }
|
|
10
|
+
}
|
|
11
|
+
`, { options }).then((res) => res.data.productVariants as { items: any[]; totalItems: number });
|
|
12
|
+
/** The newest variant: each pass's high water, and a fresh install's seed. */
|
|
13
|
+
const newest = (context: SyncContext) => variants(context, 'id updatedAt', { take: 1, sort: { updatedAt: 'DESC' } });
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Variant feed for Vendure products (ADR-060, decision 3).
|
|
17
|
+
*
|
|
18
|
+
* A variant price or stock edit bumps `ProductVariant.updatedAt` but not
|
|
19
|
+
* `Product.updatedAt`, so the product feed never sees it. This pull adapter
|
|
20
|
+
* pages changed variants with the same pass and high-water design as
|
|
21
|
+
* `createVendureProductReplication` and re-delivers their parent products,
|
|
22
|
+
* in the same document shape, into the `products` collection. It runs as a
|
|
23
|
+
* sub-adapter of the connector's combined `replication.products` adapter
|
|
24
|
+
* (`combinePullAdapters`), not as its own replication. Parents that no longer
|
|
25
|
+
* exist are skipped; deletions are the id reconcile's job.
|
|
26
|
+
*
|
|
27
|
+
* On a fresh install `seedCheckpoint` starts the cursor at the variant high
|
|
28
|
+
* water, read before the product feed's first pass, so the catalogue downloads
|
|
29
|
+
* once. An upgrade (a stored product checkpoint, none for this feed) is not
|
|
30
|
+
* seeded: its first pass re-delivers every product that has variants, once.
|
|
31
|
+
* That is deliberate: it heals any price or stock change the product feed
|
|
32
|
+
* missed before this feed existed.
|
|
33
|
+
*
|
|
34
|
+
* `variantPageSize` (the variant page `take`) is for tests and tuning.
|
|
35
|
+
*/
|
|
36
|
+
export const createVendureVariantFeedReplication = (barcodeField?: string, updatedAtSkewMs = 0, variantPageSize = MAX_TAKE): ReplicationAdapter<any, VendureProductCheckpoint> => ({
|
|
37
|
+
pull: {
|
|
38
|
+
// The next call's unchanged-high-water check then ends at one read, unless
|
|
39
|
+
// a variant changed since. The skew probe runs when a pass starts from it.
|
|
40
|
+
async seedCheckpoint(context) {
|
|
41
|
+
const head = await newest(context);
|
|
42
|
+
return { skip: 0, updatedAt: head.items[0]?.updatedAt ?? '', passHighWater: undefined, passTotal: undefined };
|
|
43
|
+
},
|
|
44
|
+
async handler(lastCheckpoint, batchSize, context) {
|
|
45
|
+
if (batchSize > MAX_TAKE || variantPageSize > MAX_TAKE) throw new Error('Vendure Admin API take must not exceed 1000');
|
|
46
|
+
let passHighWater = lastCheckpoint?.passHighWater ?? lastCheckpoint?.updatedAt ?? '';
|
|
47
|
+
let skip = lastCheckpoint?.skip ?? 0;
|
|
48
|
+
let passTotal = lastCheckpoint?.passTotal;
|
|
49
|
+
if (!skip) {
|
|
50
|
+
const head = await newest(context);
|
|
51
|
+
passHighWater = head.items[0]?.updatedAt ?? lastCheckpoint?.updatedAt ?? '';
|
|
52
|
+
if (!head.items.length || (lastCheckpoint?.updatedAt && passHighWater === lastCheckpoint.updatedAt)) {
|
|
53
|
+
return { documents: [], checkpoint: lastCheckpoint ?? { skip: 0, updatedAt: '' } };
|
|
54
|
+
}
|
|
55
|
+
await probeUpdatedAtSkew(passHighWater, updatedAtSkewMs, async (after) => {
|
|
56
|
+
const probe = await variants(context, 'id updatedAt', { take: 1, sort: { updatedAt: 'DESC' }, filter: { updatedAt: { after } } });
|
|
57
|
+
return probe.totalItems;
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
const filter = lastCheckpoint?.updatedAt
|
|
61
|
+
// Vendure after is strict; overlap by 1 ms to include timestamp ties.
|
|
62
|
+
? { updatedAt: { after: new Date(Date.parse(lastCheckpoint.updatedAt) - 1 - updatedAtSkewMs).toISOString() } }
|
|
63
|
+
: undefined;
|
|
64
|
+
const page = (from: number) => variants(context, 'id productId', { take: variantPageSize, skip: from, sort: { id: 'ASC' }, filter });
|
|
65
|
+
|
|
66
|
+
// RxDB stops pulling on a page shorter than batchSize, so keep reading
|
|
67
|
+
// variant pages until enough parents are delivered or the pass ends.
|
|
68
|
+
const seen = new Set<string>();
|
|
69
|
+
const documents: Record<string, unknown>[] = [];
|
|
70
|
+
let complete = false;
|
|
71
|
+
while (!complete && documents.length < batchSize) {
|
|
72
|
+
let data = await page(skip);
|
|
73
|
+
if (skip > 0 && (!data.items.length || data.totalItems < (passTotal ?? data.totalItems))) {
|
|
74
|
+
skip = 0; // Rows vanished mid-pass: restart; re-reads are harmless, skips are not.
|
|
75
|
+
data = await page(0);
|
|
76
|
+
}
|
|
77
|
+
if (skip === 0) passTotal = data.totalItems;
|
|
78
|
+
const parentIds = [...new Set(data.items.map((v) => String(v.productId)))].filter((id) => !seen.has(id));
|
|
79
|
+
parentIds.forEach((id) => seen.add(id));
|
|
80
|
+
if (parentIds.length) { // At most variantPageSize (<= MAX_TAKE) ids: one variant page's worth.
|
|
81
|
+
const res = await gql(context, PRODUCT_LIST_QUERY(barcodeField), {
|
|
82
|
+
options: { take: parentIds.length, filter: { id: { in: parentIds } } },
|
|
83
|
+
});
|
|
84
|
+
documents.push(...(res.data?.products?.items ?? []).map(toProductDocument));
|
|
85
|
+
}
|
|
86
|
+
skip += data.items.length;
|
|
87
|
+
complete = skip >= data.totalItems || !data.items.length;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Keep the lower bound fixed while paging by id; advance it only at pass end.
|
|
91
|
+
// RxDB merges checkpoints, so explicitly clear pass state at completion.
|
|
92
|
+
const checkpoint: VendureProductCheckpoint = complete
|
|
93
|
+
? { skip: 0, updatedAt: passHighWater, passHighWater: undefined, passTotal: undefined }
|
|
94
|
+
: { skip, updatedAt: lastCheckpoint?.updatedAt ?? '', passHighWater, passTotal };
|
|
95
|
+
// RxDB saves no checkpoint from an empty page. If this call's variant
|
|
96
|
+
// pages mapped to no live parents but the cursor moved, the cursor would
|
|
97
|
+
// stick mid-pass and a later change below it would be lost. Carry the
|
|
98
|
+
// checkpoint on one current product; re-delivering it is harmless.
|
|
99
|
+
const moved = (['skip', 'updatedAt', 'passHighWater', 'passTotal'] as const)
|
|
100
|
+
.some((key) => checkpoint[key] !== lastCheckpoint?.[key]);
|
|
101
|
+
if (!documents.length && moved) {
|
|
102
|
+
const res = await gql(context, PRODUCT_LIST_QUERY(barcodeField), { options: { take: 1 } });
|
|
103
|
+
documents.push(...(res.data?.products?.items ?? []).map(toProductDocument));
|
|
104
|
+
}
|
|
105
|
+
return { documents, checkpoint };
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
});
|
package/src/schemas/products.ts
CHANGED
|
@@ -12,7 +12,10 @@ import type { RxJsonSchema } from 'rxdb';
|
|
|
12
12
|
* - No native barcode field (custom fields only)
|
|
13
13
|
*/
|
|
14
14
|
export const vendureProductSchema: RxJsonSchema<any> = {
|
|
15
|
-
|
|
15
|
+
// 1: declares variant trackInventory/outOfStockThreshold/useGlobalOutOfStockThreshold/enabled
|
|
16
|
+
// (backlog 28). 2: declares variant taxCategory { id } (#288). A bump drops and resyncs
|
|
17
|
+
// (ConnectorSchemas, ADR-060 amendment 9).
|
|
18
|
+
version: 2,
|
|
16
19
|
primaryKey: 'id',
|
|
17
20
|
type: 'object',
|
|
18
21
|
properties: {
|
|
@@ -80,7 +83,21 @@ export const vendureProductSchema: RxJsonSchema<any> = {
|
|
|
80
83
|
currencyCode: { type: 'string' },
|
|
81
84
|
stockLevel: { type: 'string' },
|
|
82
85
|
stockOnHand: { type: 'number' },
|
|
86
|
+
stockLevels: {
|
|
87
|
+
type: 'array',
|
|
88
|
+
items: {
|
|
89
|
+
type: 'object',
|
|
90
|
+
properties: {
|
|
91
|
+
stockLocationId: { type: 'string' },
|
|
92
|
+
stockOnHand: { type: 'number' },
|
|
93
|
+
stockAllocated: { type: 'number' },
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
},
|
|
83
97
|
trackInventory: { type: 'string' },
|
|
98
|
+
outOfStockThreshold: { type: 'number' },
|
|
99
|
+
useGlobalOutOfStockThreshold: { type: 'boolean' },
|
|
100
|
+
enabled: { type: 'boolean' },
|
|
84
101
|
featuredAsset: {
|
|
85
102
|
type: ['object', 'null'],
|
|
86
103
|
properties: {
|
|
@@ -100,6 +117,7 @@ export const vendureProductSchema: RxJsonSchema<any> = {
|
|
|
100
117
|
},
|
|
101
118
|
},
|
|
102
119
|
customFields: { type: 'object' },
|
|
120
|
+
taxCategory: { type: ['object', 'null'], properties: { id: { type: 'string' } } },
|
|
103
121
|
},
|
|
104
122
|
},
|
|
105
123
|
},
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { ConnectorUnauthorizedError } from '@tallyui/core';
|
|
2
|
+
import type { SyncContext } from '@tallyui/core';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Vendure answers a dead session and a missing permission alike: HTTP 200 with GraphQL
|
|
6
|
+
* `FORBIDDEN` (#274). Asks who is signed in with the failed request's headers: `true` when
|
|
7
|
+
* nobody is, `false` when an administrator is, `undefined` when the check itself failed.
|
|
8
|
+
*/
|
|
9
|
+
export async function signedOut(context: SyncContext): Promise<boolean | undefined> {
|
|
10
|
+
try {
|
|
11
|
+
const res = await fetch(`${context.baseUrl}/admin-api`, {
|
|
12
|
+
method: 'POST',
|
|
13
|
+
headers: { 'Content-Type': 'application/json', ...context.headers },
|
|
14
|
+
body: JSON.stringify({ query: 'query { activeAdministrator { id } }' }),
|
|
15
|
+
signal: context.signal,
|
|
16
|
+
});
|
|
17
|
+
if (!res.ok) return undefined;
|
|
18
|
+
const body = await res.json() as { data?: { activeAdministrator?: { id?: unknown } | null }; errors?: unknown[] };
|
|
19
|
+
if (body.errors?.length) return undefined;
|
|
20
|
+
if (body.data?.activeAdministrator === null) return true;
|
|
21
|
+
return body.data?.activeAdministrator?.id != null ? false : undefined;
|
|
22
|
+
} catch (error) {
|
|
23
|
+
if (context.signal?.aborted) throw error;
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The error for a `FORBIDDEN` answer: only a signed-out session signs the till out. The plain
|
|
30
|
+
* errors name everything refused, in the answer's order (each GraphQL `path`, e.g. `product.variants`),
|
|
31
|
+
* for the store owner's log. One probe answers for the whole response.
|
|
32
|
+
*/
|
|
33
|
+
export async function forbiddenError(context: SyncContext, forbidden: Array<{ message?: string; path?: ReadonlyArray<string | number> }>): Promise<Error> {
|
|
34
|
+
const out = await signedOut(context);
|
|
35
|
+
if (out === true) return new ConnectorUnauthorizedError(`Vendure GraphQL error: ${forbidden[0].message}`);
|
|
36
|
+
const what = forbidden.map(({ message, path }) => (path?.length ? `${path.join('.')}: ${message}` : message)).join('; ');
|
|
37
|
+
if (out === false) return new Error(`Vendure GraphQL error: FORBIDDEN: the store refused this request although the session is signed in (a permission is missing): ${what}`);
|
|
38
|
+
return new Error(`Vendure GraphQL error: FORBIDDEN: ${what} (the session check failed, so this may be transient)`);
|
|
39
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { StoreSettingsError } from '@tallyui/core';
|
|
2
|
+
import type { StoreSettings, StoreSettingsChoice, SyncContext } from '@tallyui/core';
|
|
3
|
+
|
|
4
|
+
import { gql } from './replication/products';
|
|
5
|
+
|
|
6
|
+
const CHANNEL_AND_CATEGORIES_QUERY = `
|
|
7
|
+
query StoreSettingsChannel {
|
|
8
|
+
activeChannel { defaultCurrencyCode pricesIncludeTax defaultTaxZone { id } }
|
|
9
|
+
taxCategories { items { id isDefault } }
|
|
10
|
+
}
|
|
11
|
+
`;
|
|
12
|
+
|
|
13
|
+
const TAX_RATES_QUERY = `
|
|
14
|
+
query StoreSettingsTaxRates($zoneId: String!) {
|
|
15
|
+
taxRates(options: { take: 1000, filter: { zoneId: { eq: $zoneId } } }) {
|
|
16
|
+
items { enabled value category { id } customerGroup { id } }
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
`;
|
|
20
|
+
|
|
21
|
+
type ActiveChannel = { defaultCurrencyCode: string; pricesIncludeTax: boolean; defaultTaxZone: { id: string } | null };
|
|
22
|
+
type TaxCategory = { id: string; isDefault: boolean };
|
|
23
|
+
type TaxRateItem = { enabled: boolean; value: number; category: { id: string } | null; customerGroup: { id: string } | null };
|
|
24
|
+
|
|
25
|
+
/** `gql` throws a plain `Error` for a non-OK response or a GraphQL `errors` body; both become `StoreSettingsError('failed')` here. */
|
|
26
|
+
async function query(context: SyncContext, source: string, variables?: Record<string, unknown>): Promise<any> {
|
|
27
|
+
try {
|
|
28
|
+
return await gql(context, source, variables);
|
|
29
|
+
} catch (error) {
|
|
30
|
+
throw new StoreSettingsError('failed', error instanceof Error ? error.message : String(error));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Reads the active channel's currency and tax-inclusivity (ADR-049), and the
|
|
36
|
+
* default tax zone's enabled, non-customer-group rates keyed by tax category
|
|
37
|
+
* id, in integer ppm rounded once. Read-only (ADR-048). Ignores `choice`:
|
|
38
|
+
* Vendure's channel is already chosen by the `vendure-token` header sent
|
|
39
|
+
* with every request, so there is no `pricingContext` either.
|
|
40
|
+
*/
|
|
41
|
+
export const vendureStoreSettings = async (context: SyncContext, _choice?: StoreSettingsChoice): Promise<StoreSettings> => {
|
|
42
|
+
const head = await query(context, CHANNEL_AND_CATEGORIES_QUERY);
|
|
43
|
+
const channel = head.data?.activeChannel as ActiveChannel | undefined;
|
|
44
|
+
if (!channel) throw new StoreSettingsError('failed', 'Vendure returned no active channel');
|
|
45
|
+
const categories: TaxCategory[] = head.data?.taxCategories?.items ?? [];
|
|
46
|
+
|
|
47
|
+
let rateItems: TaxRateItem[] = [];
|
|
48
|
+
if (channel.defaultTaxZone) {
|
|
49
|
+
const ratesBody = await query(context, TAX_RATES_QUERY, { zoneId: channel.defaultTaxZone.id });
|
|
50
|
+
rateItems = ratesBody.data?.taxRates?.items ?? [];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const byCategory = new Map<string, number>();
|
|
54
|
+
for (const rate of rateItems) {
|
|
55
|
+
if (!rate.enabled || rate.customerGroup || !rate.category) continue;
|
|
56
|
+
// Rounded once, here, at the connector's edge (a backend decimal percentage -> integer ppm).
|
|
57
|
+
byCategory.set(rate.category.id, Math.round(rate.value * 10_000));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Vendure's own fallback for a variant created without a category
|
|
61
|
+
// (getTaxCategoryForNewVariant, @vendure/core 3.7.3,
|
|
62
|
+
// dist/service/services/product-variant.service.js:875): the isDefault
|
|
63
|
+
// category, or, when none is flagged, taxCategories.items[0]. When that
|
|
64
|
+
// category has no enabled rate in the zone (or there are no categories
|
|
65
|
+
// at all), `default` is 0 — that is what Vendure itself charges in that
|
|
66
|
+
// case, never a silent guess.
|
|
67
|
+
const defaultCategory = categories.find((c) => c.isDefault) ?? categories[0];
|
|
68
|
+
const taxRatesPpm: { default: number; [taxClass: string]: number } = {
|
|
69
|
+
default: defaultCategory ? byCategory.get(defaultCategory.id) ?? 0 : 0,
|
|
70
|
+
};
|
|
71
|
+
for (const [categoryId, ppm] of byCategory) taxRatesPpm[categoryId] = ppm;
|
|
72
|
+
// A category with no enabled rate in the zone is charged 0 by Vendure, as for `default` above: an explicit 0, so the
|
|
73
|
+
// till never taxes it at the default rate (#288).
|
|
74
|
+
for (const category of categories) taxRatesPpm[category.id] ??= 0;
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
currency: channel.defaultCurrencyCode,
|
|
78
|
+
pricesIncludeTax: channel.pricesIncludeTax,
|
|
79
|
+
taxRatesPpm,
|
|
80
|
+
};
|
|
81
|
+
};
|
package/src/sync/products.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { ConnectorUnauthorizedError } from '@tallyui/core';
|
|
1
2
|
import type { CollectionSync, SyncContext } from '@tallyui/core';
|
|
3
|
+
import { forbiddenError } from '../session-probe';
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* GraphQL query fragments used by the Vendure sync.
|
|
@@ -15,7 +17,7 @@ const PRODUCT_LIST_QUERY = `
|
|
|
15
17
|
}
|
|
16
18
|
`;
|
|
17
19
|
|
|
18
|
-
const PRODUCT_DETAIL_QUERY = `
|
|
20
|
+
const PRODUCT_DETAIL_QUERY = (barcodeField?: string) => `
|
|
19
21
|
query GetProduct($id: ID!) {
|
|
20
22
|
product(id: $id) {
|
|
21
23
|
id
|
|
@@ -36,12 +38,11 @@ const PRODUCT_DETAIL_QUERY = `
|
|
|
36
38
|
price
|
|
37
39
|
priceWithTax
|
|
38
40
|
currencyCode
|
|
39
|
-
|
|
40
|
-
stockOnHand
|
|
41
|
+
stockLevels { stockLocationId stockOnHand stockAllocated }
|
|
41
42
|
trackInventory
|
|
42
43
|
featuredAsset { id preview }
|
|
43
44
|
options { id name code }
|
|
44
|
-
customFields
|
|
45
|
+
${barcodeField ? `customFields { ${barcodeField} }` : ''}
|
|
45
46
|
}
|
|
46
47
|
}
|
|
47
48
|
}
|
|
@@ -53,7 +54,7 @@ const PRODUCT_DETAIL_QUERY = `
|
|
|
53
54
|
* Uses the Admin GraphQL API. Vendure uses offset-based pagination
|
|
54
55
|
* with `take` and `skip` options.
|
|
55
56
|
*/
|
|
56
|
-
export const
|
|
57
|
+
export const createVendureProductSync = (barcodeField?: string): CollectionSync => ({
|
|
57
58
|
fetchAllIds: async (context: SyncContext) => {
|
|
58
59
|
const entries: { id: string; dateModified?: string }[] = [];
|
|
59
60
|
const take = 100;
|
|
@@ -86,7 +87,7 @@ export const vendureProductSync: CollectionSync = {
|
|
|
86
87
|
const products: any[] = [];
|
|
87
88
|
// Vendure doesn't support batch-by-IDs natively, so fetch individually
|
|
88
89
|
for (const id of ids) {
|
|
89
|
-
const res = await gql(context, PRODUCT_DETAIL_QUERY, { id });
|
|
90
|
+
const res = await gql(context, PRODUCT_DETAIL_QUERY(barcodeField), { id });
|
|
90
91
|
if (res.data?.product) {
|
|
91
92
|
products.push(res.data.product);
|
|
92
93
|
}
|
|
@@ -118,7 +119,7 @@ export const vendureProductSync: CollectionSync = {
|
|
|
118
119
|
|
|
119
120
|
// Fetch full details for each modified product
|
|
120
121
|
for (const item of data.items ?? []) {
|
|
121
|
-
const detail = await gql(context, PRODUCT_DETAIL_QUERY, { id: item.id });
|
|
122
|
+
const detail = await gql(context, PRODUCT_DETAIL_QUERY(barcodeField), { id: item.id });
|
|
122
123
|
if (detail.data?.product) {
|
|
123
124
|
products.push(detail.data.product);
|
|
124
125
|
}
|
|
@@ -129,7 +130,9 @@ export const vendureProductSync: CollectionSync = {
|
|
|
129
130
|
|
|
130
131
|
return products;
|
|
131
132
|
},
|
|
132
|
-
};
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
export const vendureProductSync = createVendureProductSync();
|
|
133
136
|
|
|
134
137
|
/**
|
|
135
138
|
* Helper to execute a GraphQL query against the Vendure Admin API.
|
|
@@ -149,6 +152,10 @@ async function gql(
|
|
|
149
152
|
signal: context.signal,
|
|
150
153
|
});
|
|
151
154
|
|
|
155
|
+
if (res.status === 401 || res.status === 403) throw new ConnectorUnauthorizedError(`Vendure API error: ${res.status}`, res.status);
|
|
152
156
|
if (!res.ok) throw new Error(`Vendure API error: ${res.status}`);
|
|
153
|
-
|
|
157
|
+
const body: { data?: any; errors?: Array<{ message?: string; path?: Array<string | number>; extensions?: { code?: string } }> } = await res.json();
|
|
158
|
+
const forbidden = body.errors?.filter((error) => error.extensions?.code === 'FORBIDDEN') ?? [];
|
|
159
|
+
if (forbidden.length) throw await forbiddenError(context, forbidden);
|
|
160
|
+
return body;
|
|
154
161
|
}
|