@tallyui/connector-vendure 1.0.0 → 2.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tallyui/connector-vendure",
3
- "version": "1.0.0",
3
+ "version": "2.0.0",
4
4
  "type": "module",
5
5
  "description": "Vendure connector for Tally UI",
6
6
  "main": "./dist/index.js",
@@ -15,13 +15,22 @@
15
15
  },
16
16
  "files": [
17
17
  "dist",
18
- "src"
18
+ "src",
19
+ "!src/**/*.test.ts",
20
+ "!src/**/*.fixture.json"
19
21
  ],
20
22
  "license": "MIT",
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/TallyUI/tallyui.git",
26
+ "directory": "connectors/vendure"
27
+ },
21
28
  "peerDependencies": {
22
- "@tallyui/core": "0.2.0"
29
+ "@tallyui/core": "2.0.0"
23
30
  },
24
31
  "devDependencies": {
32
+ "@tallyui/database": "2.0.0",
33
+ "@tallyui/pos": "2.0.0",
25
34
  "rxdb": "16.21.1"
26
35
  },
27
36
  "scripts": {
package/src/auth.ts ADDED
@@ -0,0 +1,74 @@
1
+ import { SignInError, type ConnectorAuth } from '@tallyui/core';
2
+
3
+ const LOGIN = `mutation Login($email: String!, $password: String!) {
4
+ login(username: $email, password: $password) {
5
+ __typename
6
+ ... on CurrentUser { id }
7
+ ... on ErrorResult { errorCode message }
8
+ }
9
+ }`;
10
+
11
+ type LoginBody = { data?: { login?: { __typename: string; errorCode?: string; message?: string } }; errors?: { message: string }[] };
12
+
13
+ // Signs in on the Admin API; the session token comes back in the vendure-auth-token header.
14
+ // Vendure sessions roll forward on use, so there is no expiresAt.
15
+ export const vendureSignIn: NonNullable<ConnectorAuth['signIn']> = async (baseUrl, { email, password }, init = {}) => {
16
+ const doFetch = init.fetch ?? fetch;
17
+ let res: Response;
18
+ try {
19
+ res = await doFetch(`${baseUrl}/admin-api`, {
20
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: init.signal,
21
+ body: JSON.stringify({ query: LOGIN, variables: { email, password } }),
22
+ });
23
+ } catch (error) {
24
+ if ((error as { name?: unknown })?.name === 'AbortError' || init.signal?.aborted) throw error;
25
+ throw new SignInError('failed', `Could not reach Vendure at ${baseUrl}: ${error instanceof Error ? error.message : String(error)}`);
26
+ }
27
+ let body: LoginBody;
28
+ try {
29
+ body = await res.json();
30
+ } catch {
31
+ throw new SignInError('server_error', `Vendure sent an unreadable response (HTTP ${res.status})`, res.status);
32
+ }
33
+ if (!res.ok) throw new SignInError('server_error', body?.errors?.[0]?.message ?? `Vendure sign-in failed (HTTP ${res.status})`, res.status);
34
+ const login = body?.data?.login;
35
+ if (body?.errors?.length || !login) {
36
+ throw new SignInError('server_error', body?.errors?.[0]?.message ?? `Vendure sign-in failed (HTTP ${res.status})`, res.status);
37
+ }
38
+ if (login.errorCode === 'INVALID_CREDENTIALS_ERROR') throw new SignInError('invalid_credentials', login.message ?? 'Invalid email or password');
39
+ // Native email/password auth is disabled on the server, so it needs a sign-in flow this connector can't do.
40
+ if (login.errorCode === 'NATIVE_AUTH_STRATEGY_ERROR') throw new SignInError('unsupported', login.message ?? 'Native email and password sign-in is disabled on this Vendure server');
41
+ if (login.__typename !== 'CurrentUser') throw new SignInError('server_error', login.message ?? `Vendure sign-in failed (${login.__typename})`, res.status);
42
+ const token = res.headers.get('vendure-auth-token');
43
+ if (!token) {
44
+ throw new SignInError('unsupported', "Vendure signed in but sent no 'vendure-auth-token' header. Add 'bearer' to the server's authOptions.tokenMethod. This connector reads the default header name, so a server that renames authOptions.authTokenHeaderKey also gives this error.");
45
+ }
46
+ return { token, expiresAt: undefined };
47
+ };
48
+
49
+ // Warns once per module lifetime when a caller still relies on the deprecated auth_token alias.
50
+ let warnedAuthToken = false;
51
+
52
+ export const vendureAuth: ConnectorAuth = {
53
+ type: 'Vendure Admin API',
54
+ fields: [
55
+ { key: 'url', label: 'Backend URL', type: 'url', placeholder: 'https://my-vendure-server.com', required: true },
56
+ { key: 'email', label: 'Email', type: 'text', required: true },
57
+ { key: 'password', label: 'Password', type: 'password', required: true },
58
+ { key: 'channel_token', label: 'Channel token (optional)', type: 'text' },
59
+ ],
60
+ // An API key (Vendure 3.6+) wins over a signed-in session token; channel_token selects a non-default channel.
61
+ // auth_token is the deprecated name for token, kept so existing apps keep working.
62
+ getHeaders: (credentials): Record<string, string> => {
63
+ if (credentials.token === undefined && credentials.auth_token !== undefined && !warnedAuthToken) {
64
+ warnedAuthToken = true;
65
+ console.warn("@tallyui/connector-vendure: the 'auth_token' credential is deprecated; store the credential as 'token' instead.");
66
+ }
67
+ const token = credentials.token ?? credentials.auth_token;
68
+ return {
69
+ ...(credentials.api_key ? { 'vendure-api-key': credentials.api_key } : token ? { Authorization: `Bearer ${token}` } : {}),
70
+ ...(credentials.channel_token ? { 'vendure-token': credentials.channel_token } : {}),
71
+ };
72
+ },
73
+ signIn: vendureSignIn,
74
+ };
@@ -0,0 +1,18 @@
1
+ import type { SyncContext } from '@tallyui/core';
2
+ import { gql } from './replication/products';
3
+
4
+ const GLOBAL_SETTINGS_QUERY = `
5
+ query GlobalStockSettings {
6
+ globalSettings { trackInventory outOfStockThreshold }
7
+ }
8
+ `;
9
+
10
+ /**
11
+ * The channel-wide stock defaults an `INHERIT` variant falls back to
12
+ * (backlog 28). Read with `vendureGlobalStockSettings` after sign-in, like
13
+ * `pricesIncludeTax` from `storeSettings`.
14
+ */
15
+ export async function vendureGlobalStockSettings(context: SyncContext): Promise<{ trackInventory: boolean; outOfStockThreshold: number }> {
16
+ const res = await gql(context, GLOBAL_SETTINGS_QUERY);
17
+ return res.data.globalSettings;
18
+ }
package/src/index.ts CHANGED
@@ -1,9 +1,16 @@
1
- import type { TallyConnector } from '@tallyui/core';
1
+ import { combinePullAdapters, createReconcileFeed, type TallyConnector } from '@tallyui/core';
2
2
 
3
+ import { vendureAuth } from './auth';
3
4
  import { vendureProductSchema } from './schemas/products';
4
- import { vendureProductTraits } from './traits/product';
5
- import { vendureProductSync } from './sync/products';
6
- import { vendureProductReplication } from './replication/products';
5
+ import { createVendureProductTraits } from './traits/product';
6
+ import { createVendureProductSync } from './sync/products';
7
+ import { createVendureProductReplication } from './replication/products';
8
+ import { createVendureVariantFeedReplication } from './replication/variant-feed';
9
+ import { vendureStockReconcile } from './reconcile/stock';
10
+ import { createFetchByIds, fetchPages, variantIds } from './reconcile/ids';
11
+ import { fetchPages as fetchPricePages, fingerprint as priceFingerprint } from './reconcile/prices';
12
+ import { vendureStoreSettings } from './store-settings';
13
+ import { vendureGlobalStockSettings } from './global-settings';
7
14
 
8
15
  /**
9
16
  * Vendure connector for Tally UI.
@@ -19,55 +26,71 @@ import { vendureProductReplication } from './replication/products';
19
26
  * <App />
20
27
  * </ConnectorProvider>
21
28
  * ```
29
+ *
30
+ * `pricesIncludeTax` must equal the POS tax setting (`TaxContext.pricesIncludeTax`);
31
+ * pass `settings.pricesIncludeTax` from `storeSettings`. `globalTrackInventory`
32
+ * and `globalOutOfStockThreshold` are the channel's stock defaults; read them
33
+ * with `vendureGlobalStockSettings` after sign-in, like `pricesIncludeTax`
34
+ * from `storeSettings`.
22
35
  */
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,
36
+ export const createVendureConnector = (options: {
37
+ barcodeField?: string; stockLocationId?: string; pricesIncludeTax?: boolean; updatedAtSkewMs?: number;
38
+ globalTrackInventory?: boolean; globalOutOfStockThreshold?: number;
39
+ } = {}): TallyConnector => {
40
+ // The id reconcile's corrections reach `products` only through this pull adapter (ADR-060).
41
+ const idFeed = createReconcileFeed({ fetchByIds: createFetchByIds(options.barcodeField) });
42
+ return {
43
+ id: 'vendure',
44
+ name: 'Vendure',
45
+ description: 'Connect to Vendure backends via the Admin GraphQL API',
46
+ icon: undefined,
47
+
48
+ auth: vendureAuth,
28
49
 
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
- },
50
+ schemas: {
51
+ products: vendureProductSchema,
52
+ },
51
53
 
52
- schemas: {
53
- products: vendureProductSchema,
54
- },
54
+ traits: {
55
+ product: createVendureProductTraits(
56
+ options.barcodeField, options.stockLocationId, options.pricesIncludeTax,
57
+ options.globalTrackInventory, options.globalOutOfStockThreshold,
58
+ ),
59
+ },
55
60
 
56
- traits: {
57
- product: vendureProductTraits,
58
- },
61
+ sync: {
62
+ products: createVendureProductSync(options.barcodeField),
63
+ },
59
64
 
60
- sync: {
61
- products: vendureProductSync,
62
- },
65
+ replication: {
66
+ // One replication per collection: the product, variant and id-reconcile feeds
67
+ // share it (ADR-060). reconcile is last so its fetch wins duplicates.
68
+ products: combinePullAdapters({
69
+ products: createVendureProductReplication(options.barcodeField, options.updatedAtSkewMs),
70
+ variants: createVendureVariantFeedReplication(options.barcodeField, options.updatedAtSkewMs),
71
+ reconcile: idFeed.adapter,
72
+ }, { legacyKey: 'products' }),
73
+ },
63
74
 
64
- replication: {
65
- products: vendureProductReplication,
66
- },
75
+ reconcile: {
76
+ stock: vendureStockReconcile,
77
+ ids: { fetchPages, variantIds, enqueue: idFeed.enqueue },
78
+ prices: { fetchPages: fetchPricePages, fingerprint: priceFingerprint, enqueue: idFeed.enqueue },
79
+ },
80
+
81
+ storeSettings: vendureStoreSettings,
82
+ };
67
83
  };
68
84
 
85
+ export const vendureConnector = createVendureConnector();
86
+
69
87
  // Re-export pieces for advanced usage
88
+ export { vendureAuth, vendureSignIn } from './auth';
70
89
  export { vendureProductSchema } from './schemas/products';
71
90
  export { vendureProductTraits } from './traits/product';
72
91
  export { vendureProductSync } from './sync/products';
73
- export { vendureProductReplication } from './replication/products';
92
+ export { createVendureProductReplication, vendureProductReplication } from './replication/products';
93
+ export { createVendureVariantFeedReplication } from './replication/variant-feed';
94
+ export { vendureStockReconcile } from './reconcile/stock';
95
+ export { vendureStoreSettings } from './store-settings';
96
+ export { vendureGlobalStockSettings } from './global-settings';
@@ -0,0 +1,38 @@
1
+ import type { SyncContext } from '@tallyui/core';
2
+ import { gql, PRODUCT_LIST_QUERY, toProductDocument } from '../replication/products';
3
+
4
+ /** Vendure Admin API maximum `take`; also the id-listing page size. */
5
+ const PAGE_SIZE = 1000;
6
+
7
+ const ID_LIST_QUERY = `
8
+ query GetProductIds($options: ProductListOptions) {
9
+ products(options: $options) { items { id variants { id } } totalItems }
10
+ }
11
+ `;
12
+
13
+ /** Every live product id with its live variant ids, one request per page (ADR-060). */
14
+ export async function* fetchPages(context: SyncContext): AsyncIterable<Array<{ id: string; variantIds: string[] }>> {
15
+ // total starts at 1 so the loop runs at least once, before totalItems is known.
16
+ for (let skip = 0, total = 1; skip < total; skip += PAGE_SIZE) {
17
+ const res = await gql(context, ID_LIST_QUERY, { options: { take: PAGE_SIZE, skip, sort: { id: 'ASC' } } });
18
+ const { items, totalItems } = res.data.products as {
19
+ items: Array<{ id: string; variants: Array<{ id: string }> }>; totalItems: number;
20
+ };
21
+ total = totalItems;
22
+ yield items.map((p) => ({ id: String(p.id), variantIds: (p.variants ?? []).map((v) => String(v.id)) }));
23
+ }
24
+ }
25
+
26
+ /** The variant ids a local product document lists. */
27
+ export function variantIds(doc: any): string[] {
28
+ return (doc.variants ?? []).map((v: any) => String(v.id));
29
+ }
30
+
31
+ /** `fetchByIds` for `createReconcileFeed`, in the replication feeds' document shape. */
32
+ export function createFetchByIds(barcodeField?: string) {
33
+ return async function fetchByIds(ids: string[], context: SyncContext): Promise<Record<string, unknown>[]> {
34
+ if (!ids.length) return [];
35
+ const res = await gql(context, PRODUCT_LIST_QUERY(barcodeField), { options: { take: ids.length, filter: { id: { in: ids } } } });
36
+ return (res.data?.products?.items ?? []).map(toProductDocument);
37
+ };
38
+ }
@@ -0,0 +1,58 @@
1
+ import type { SyncContext } from '@tallyui/core';
2
+ import { gql } from '../replication/products';
3
+
4
+ /** Vendure Admin API maximum `take`; also the price-listing page size (ADR-060). */
5
+ const PAGE_SIZE = 1000;
6
+
7
+ /**
8
+ * The nightly base-price backstop's interval: tax-rate changes move
9
+ * `priceWithTax` without bumping `updatedAt` (ADR-060).
10
+ */
11
+ export const VENDURE_PRICE_RECONCILE_INTERVAL_MS = 24 * 60 * 60 * 1000;
12
+
13
+ const PRICE_LIST_QUERY = `
14
+ query GetVariantPrices($options: ProductVariantListOptions) {
15
+ productVariants(options: $options) { items { id productId price priceWithTax currencyCode } totalItems }
16
+ }
17
+ `;
18
+
19
+ /** `${variantId}:${currencyCode}:${price}:${priceWithTax}` entries, sorted and joined. */
20
+ const fingerprintOf = (entries: string[]): string => [...entries].sort().join('|');
21
+
22
+ /**
23
+ * Every product's price fingerprint, one `productVariants` page at a time
24
+ * (ADR-060's nightly backstop). The listing is not grouped by product, so a
25
+ * product's variants may land on different pages; a page's yielded entry is
26
+ * always recomputed from every variant seen for that product so far, so the
27
+ * last page that touches a product carries its complete, correct
28
+ * fingerprint. The reconcile runner keeps only the latest entry per id, so
29
+ * earlier, partial entries are harmless.
30
+ */
31
+ export async function* fetchPages(context: SyncContext): AsyncIterable<Map<string, string>> {
32
+ const byProduct = new Map<string, string[]>();
33
+ for (let skip = 0, total = 1; skip < total; skip += PAGE_SIZE) {
34
+ const res = await gql(context, PRICE_LIST_QUERY, { options: { take: PAGE_SIZE, skip, sort: { id: 'ASC' } } });
35
+ const { items, totalItems } = res.data.productVariants as {
36
+ items: Array<{ id: string; productId: string; price: number; priceWithTax: number; currencyCode: string }>;
37
+ totalItems: number;
38
+ };
39
+ total = totalItems;
40
+ const touched = new Set<string>();
41
+ for (const variant of items) {
42
+ const productId = String(variant.productId);
43
+ touched.add(productId);
44
+ const entries = byProduct.get(productId) ?? [];
45
+ byProduct.set(productId, entries);
46
+ entries.push(`${variant.id}:${variant.currencyCode}:${variant.price}:${variant.priceWithTax}`);
47
+ }
48
+ yield new Map([...touched].map((id) => [id, fingerprintOf(byProduct.get(id) ?? [])]));
49
+ }
50
+ }
51
+
52
+ /** The same fingerprint, computed from a local product document's variants. Replicated Vendure documents already carry `price`, `priceWithTax` and `currencyCode`. */
53
+ export function fingerprint(doc: any): string {
54
+ const entries: string[] = (doc.variants ?? []).map(
55
+ (v: any) => `${v.id}:${v.currencyCode}:${v.price}:${v.priceWithTax}`,
56
+ );
57
+ return fingerprintOf(entries);
58
+ }
@@ -0,0 +1,48 @@
1
+ import type { StockReconcileAdapter } from '@tallyui/core';
2
+
3
+ import { gql } from '../replication/products';
4
+
5
+ type StockLevel = { stockLocationId: string; stockOnHand: number; stockAllocated: number };
6
+
7
+ /** Admin API maximum `take`. */
8
+ const PAGE_SIZE = 1000;
9
+
10
+ const VARIANT_STOCK_QUERY = `
11
+ query VariantStock($options: ProductVariantListOptions) {
12
+ productVariants(options: $options) {
13
+ items { id stockLevels { stockLocationId stockOnHand stockAllocated } }
14
+ totalItems
15
+ }
16
+ }
17
+ `;
18
+
19
+ /** Compared by value as sorted tuples; neither key nor level order is guaranteed. */
20
+ const levelsKey = (levels: StockLevel[] = []) =>
21
+ levels.map((l) => JSON.stringify([l.stockLocationId, l.stockOnHand, l.stockAllocated])).sort().join();
22
+
23
+ /**
24
+ * Stock reconciler for Vendure products (ADR-060). Stock edits bump only the
25
+ * variant's updatedAt and allocations bump nothing, so replication misses them.
26
+ * Pages are keyed by variant id; each value is the variant's stockLevels.
27
+ */
28
+ export const vendureStockReconcile: StockReconcileAdapter = {
29
+ async *fetchPages(context) {
30
+ for (let skip = 0, total = 1; skip < total; skip += PAGE_SIZE) {
31
+ const body = await gql(context, VARIANT_STOCK_QUERY, { options: { take: PAGE_SIZE, skip, sort: { id: 'ASC' } } });
32
+ const { items, totalItems } = body.data.productVariants as { items: Array<{ id: string; stockLevels: StockLevel[] }>; totalItems: number };
33
+ total = totalItems;
34
+ yield new Map(items.map((v) => [v.id, v.stockLevels]));
35
+ }
36
+ },
37
+ overlay(doc, stock) {
38
+ const variants: any[] = doc.variants ?? [];
39
+ let changed = false;
40
+ const next = variants.map((v) => {
41
+ const levels = stock.get(v.id) as StockLevel[] | undefined;
42
+ if (!levels || levelsKey(levels) === levelsKey(v.stockLevels)) return v;
43
+ changed = true;
44
+ return { ...v, stockLevels: levels };
45
+ });
46
+ return changed ? { variants: next } : undefined;
47
+ },
48
+ };