@solumflow-app/crm-client 0.1.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,127 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/mirror.ts
21
+ var mirror_exports = {};
22
+ __export(mirror_exports, {
23
+ createCatalogueMirror: () => createCatalogueMirror
24
+ });
25
+ module.exports = __toCommonJS(mirror_exports);
26
+ var DEFAULT_PAGE_SIZE = 50;
27
+ var PRODUCT_CEILING = 1e5;
28
+ function createCatalogueMirror(options) {
29
+ const pageSize = options.pageSize ?? DEFAULT_PAGE_SIZE;
30
+ async function pull(ids) {
31
+ const upserted = [];
32
+ const removed = [];
33
+ const failed = [];
34
+ for (const id of ids) {
35
+ let product;
36
+ try {
37
+ product = await options.client.getProduct(id);
38
+ } catch {
39
+ failed.push(id);
40
+ continue;
41
+ }
42
+ if (product === null) {
43
+ await options.target.remove({ id });
44
+ removed.push(id);
45
+ continue;
46
+ }
47
+ await options.target.upsert({ id, product });
48
+ upserted.push(id);
49
+ }
50
+ return { upserted, removed, failed };
51
+ }
52
+ async function syncAll() {
53
+ const seen = [];
54
+ const positions = /* @__PURE__ */ new Set();
55
+ let cursor = null;
56
+ do {
57
+ const page = await options.client.getProducts({
58
+ limit: pageSize,
59
+ cursor
60
+ });
61
+ for (const item of page.data) {
62
+ seen.push(item.id);
63
+ }
64
+ cursor = page.nextCursor;
65
+ if (cursor !== null) {
66
+ if (positions.has(cursor)) {
67
+ throw new Error(
68
+ "crm-client: the catalogue handed back a cursor it had already given, so the walk is not advancing. Nothing was removed."
69
+ );
70
+ }
71
+ positions.add(cursor);
72
+ }
73
+ if (seen.length > PRODUCT_CEILING) {
74
+ throw new Error(
75
+ `crm-client: more than ${PRODUCT_CEILING} products were walked without reaching the end of the catalogue. Nothing was removed.`
76
+ );
77
+ }
78
+ } while (cursor);
79
+ const pulled = await pull(seen);
80
+ const removed = [...pulled.removed];
81
+ const held = pulled.failed.length ? void 0 : await options.target.listMirroredIds?.();
82
+ if (held) {
83
+ const present = new Set(seen);
84
+ for (const id of held) {
85
+ if (!present.has(id)) {
86
+ await options.target.remove({ id });
87
+ removed.push(id);
88
+ }
89
+ }
90
+ }
91
+ return {
92
+ upserted: pulled.upserted,
93
+ removed,
94
+ failed: pulled.failed,
95
+ resynchronised: true
96
+ };
97
+ }
98
+ return {
99
+ syncAll,
100
+ async apply(payload) {
101
+ if (!payload.type.startsWith("product.")) {
102
+ return { upserted: [], removed: [], failed: [], resynchronised: false };
103
+ }
104
+ if (payload.truncated === true) {
105
+ return syncAll();
106
+ }
107
+ if (payload.type === "product.deleted") {
108
+ for (const id of payload.ids) {
109
+ await options.target.remove({ id });
110
+ }
111
+ return {
112
+ upserted: [],
113
+ removed: [...payload.ids],
114
+ failed: [],
115
+ resynchronised: false
116
+ };
117
+ }
118
+ const pulled = await pull(payload.ids);
119
+ return { ...pulled, resynchronised: false };
120
+ }
121
+ };
122
+ }
123
+ // Annotate the CommonJS export names for ESM import in node:
124
+ 0 && (module.exports = {
125
+ createCatalogueMirror
126
+ });
127
+ //# sourceMappingURL=mirror.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/mirror.ts"],"sourcesContent":["import type { CrmClient } from './client';\nimport type { ApiWebhookPayload, ProductDetail } from './types';\n\nconst DEFAULT_PAGE_SIZE = 50;\n/**\n * Where a walk stops believing itself.\n *\n * Not a limit on how large a catalogue may be -- it is set far past any real\n * one on purpose. Reaching it means the listing is not ending, which is a fault\n * and not a big shop, and the difference decides what happens: a fault throws.\n */\nconst PRODUCT_CEILING = 100_000;\n\n/**\n * Where a mirrored catalogue is kept, in whatever vocabulary that store uses.\n *\n * This is the one part a site has to write, and it is deliberately the only\n * part. The shape of the local collection is not a detail this package could\n * guess: a Payload shop built on the ecommerce plugin stores `title`,\n * `priceInEUR` and a `gallery` of uploads, the catalogue here answers `name`,\n * `priceFromCents` and `images` of URLs, and no mapping between the two is\n * general — the next shop names them differently again, or keeps only three of\n * them, or has no images at all.\n *\n * What *is* general is everything around it, and that is what this file owns:\n * which ids a delivery actually changed, what to do when the id list came back\n * truncated, and the fact that a product which has stopped answering has to be\n * removed rather than skipped.\n */\nexport interface MirrorTarget {\n /** Write it, whatever \"it\" looks like locally. Called once per product. */\n upsert(input: { id: string; product: ProductDetail }): void | Promise<void>;\n /**\n * Take it out of the mirror.\n *\n * Called for a `product.deleted` delivery and, just as importantly, for a\n * product that no longer answers — a seller who moved something back to draft\n * produces no deletion event, because nothing was deleted.\n */\n remove(input: { id: string }): void | Promise<void>;\n /**\n * Every id currently held in the mirror.\n *\n * Only consulted on a full resynchronisation, and only to work out what to\n * take away. Leave it out and a resync will add and update but never remove,\n * which is a reasonable choice for a store where deletions are handled by\n * hand — and a bad surprise if you expected otherwise, hence this note.\n */\n listMirroredIds?(): string[] | Promise<string[]>;\n}\n\nexport interface MirrorReport {\n /** Ids written. */\n upserted: string[];\n /** Ids taken out. */\n removed: string[];\n /**\n * Ids that could not be fetched, and are therefore neither.\n *\n * A rate limit, a five-hundred, a key revoked mid-run. They are reported\n * rather than thrown so that the rest of the batch still lands — but an empty\n * array is the only outcome that means \"this mirror is now correct\". Anything\n * else wants a retry, and the ids to retry are right here.\n */\n failed: string[];\n /** True when the whole catalogue was walked instead of the named ids. */\n resynchronised: boolean;\n}\n\nexport interface CatalogueMirrorOptions {\n client: CrmClient;\n target: MirrorTarget;\n /** How many products to ask for per page during a resync. */\n pageSize?: number;\n}\n\nexport interface CatalogueMirror {\n /** Act on one verified delivery. Events about other subjects do nothing. */\n apply(payload: ApiWebhookPayload): Promise<MirrorReport>;\n /** Walk the whole catalogue. Safe to run on a schedule or by hand. */\n syncAll(): Promise<MirrorReport>;\n}\n\n/**\n * Keeps a local copy of the catalogue in step with the one over the wire.\n *\n * A delivery names ids and nothing else, on purpose, so this is where the\n * fetching happens — and that turns out to be the right place for three\n * decisions that are easy to get subtly wrong:\n *\n * - **A product that answers `null` is removed, not skipped.** The only events\n * are \"changed\" and \"deleted\", and moving something back to draft is neither:\n * it produces a `product.changed` for an id that then refuses to answer. A\n * mirror that skips it keeps an unpublished product on the shop's shelves.\n * - **`truncated` means walk everything.** It is set when a burst of changes\n * overflowed the batch, so the id list is a fragment and acting on only those\n * ids would leave the rest quietly stale. The flag is read as `=== true`\n * rather than for truthiness, because an older sender that omits it must not\n * be read as \"the list is complete\".\n * - **Deliveries arrive more than once and out of order.** Both operations here\n * are write-the-current-state rather than apply-a-difference, so a repeat\n * costs a fetch and changes nothing, and a late message still lands on the\n * present truth rather than on a stale snapshot.\n */\nexport function createCatalogueMirror(\n options: CatalogueMirrorOptions,\n): CatalogueMirror {\n const pageSize = options.pageSize ?? DEFAULT_PAGE_SIZE;\n\n /**\n * Fetches each id and writes what it finds, and does not let one bad id take\n * the rest of the batch with it.\n *\n * `getProduct` answers `null` for something that is gone and throws for\n * everything else — a rate limit, a five-hundred, a key that was revoked\n * between two calls. Letting that throw escape would abort the loop partway\n * with the ids already written thrown away, and the caller would learn only\n * that \"it failed\": not which items landed, and not which one was the poison.\n * A retry then starts from the top and starves everything after the bad id\n * again, on every attempt, until the underlying fault clears.\n *\n * So a failure is collected rather than thrown, and it travels in the report.\n * What the caller does about it is theirs to decide — but they can only\n * decide if they are told.\n */\n async function pull(ids: readonly string[]) {\n const upserted: string[] = [];\n const removed: string[] = [];\n const failed: string[] = [];\n\n for (const id of ids) {\n let product: ProductDetail | null;\n\n try {\n product = await options.client.getProduct(id);\n } catch {\n failed.push(id);\n\n continue;\n }\n\n if (product === null) {\n await options.target.remove({ id });\n removed.push(id);\n\n continue;\n }\n\n await options.target.upsert({ id, product });\n upserted.push(id);\n }\n\n return { upserted, removed, failed };\n }\n\n /**\n * Walks the whole catalogue, and refuses to pretend it did when it did not.\n *\n * The two guards below both stop the loop, and it matters a great deal that\n * they stop it by throwing. A partial walk that returned normally would go on\n * to the removal step with an incomplete `seen`, and take out every product\n * past the point it stopped — reported as a successful resynchronisation. On\n * a large catalogue that is most of a shop, deleted quietly, by the routine\n * whose job is to keep it correct.\n *\n * A repeated cursor is the precise signature of the thing the ceiling was\n * really there for: a listing that hands back the same position for ever.\n * The ceiling stays as well, for the case where the cursor advances and the\n * end never comes, and it is set where no real catalogue reaches it — it says\n * \"something is wrong\", not \"this shop is too big\".\n */\n async function syncAll(): Promise<MirrorReport> {\n const seen: string[] = [];\n const positions = new Set<string>();\n\n let cursor: string | null = null;\n\n do {\n const page = await options.client.getProducts({\n limit: pageSize,\n cursor,\n });\n\n for (const item of page.data) {\n seen.push(item.id);\n }\n\n cursor = page.nextCursor;\n\n if (cursor !== null) {\n if (positions.has(cursor)) {\n throw new Error(\n 'crm-client: the catalogue handed back a cursor it had already ' +\n 'given, so the walk is not advancing. Nothing was removed.',\n );\n }\n\n positions.add(cursor);\n }\n\n if (seen.length > PRODUCT_CEILING) {\n throw new Error(\n `crm-client: more than ${PRODUCT_CEILING} products were walked ` +\n 'without reaching the end of the catalogue. Nothing was removed.',\n );\n }\n } while (cursor);\n\n const pulled = await pull(seen);\n const removed = [...pulled.removed];\n\n /*\n * The removal step is skipped entirely when anything failed to fetch, for\n * the same reason an unfinished walk throws: \"what the catalogue no longer\n * has\" is worked out by subtracting what was seen from what is held, and a\n * subtraction with a hole in the first set takes out live products. A\n * mirror that is behind is a nuisance; a mirror that deleted half a shop\n * because of a rate limit is not.\n */\n const held = pulled.failed.length\n ? undefined\n : await options.target.listMirroredIds?.();\n\n if (held) {\n const present = new Set(seen);\n\n for (const id of held) {\n if (!present.has(id)) {\n await options.target.remove({ id });\n removed.push(id);\n }\n }\n }\n\n return {\n upserted: pulled.upserted,\n removed,\n failed: pulled.failed,\n resynchronised: true,\n };\n }\n\n return {\n syncAll,\n\n async apply(payload) {\n if (!payload.type.startsWith('product.')) {\n return { upserted: [], removed: [], failed: [], resynchronised: false };\n }\n\n if (payload.truncated === true) {\n return syncAll();\n }\n\n if (payload.type === 'product.deleted') {\n for (const id of payload.ids) {\n await options.target.remove({ id });\n }\n\n /*\n * Removed without fetching, and that is deliberate rather than an\n * oversight of the \"always write current state\" rule the rest of this\n * file follows. `product.deleted` fires on a real row deletion and ids\n * are uuids, so the id in this message can never name something live\n * again -- a fetch here could only ever answer `null` and then remove\n * it anyway, at the cost of a request per id. Archiving something takes\n * the other path: it is an update, so it arrives as `product.changed`,\n * stops answering, and is removed by `pull` above.\n */\n return {\n upserted: [],\n removed: [...payload.ids],\n failed: [],\n resynchronised: false,\n };\n }\n\n const pulled = await pull(payload.ids);\n\n return { ...pulled, resynchronised: false };\n },\n };\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,95 @@
1
+ import type { CrmClient } from './client';
2
+ import type { ApiWebhookPayload, ProductDetail } from './types';
3
+ /**
4
+ * Where a mirrored catalogue is kept, in whatever vocabulary that store uses.
5
+ *
6
+ * This is the one part a site has to write, and it is deliberately the only
7
+ * part. The shape of the local collection is not a detail this package could
8
+ * guess: a Payload shop built on the ecommerce plugin stores `title`,
9
+ * `priceInEUR` and a `gallery` of uploads, the catalogue here answers `name`,
10
+ * `priceFromCents` and `images` of URLs, and no mapping between the two is
11
+ * general — the next shop names them differently again, or keeps only three of
12
+ * them, or has no images at all.
13
+ *
14
+ * What *is* general is everything around it, and that is what this file owns:
15
+ * which ids a delivery actually changed, what to do when the id list came back
16
+ * truncated, and the fact that a product which has stopped answering has to be
17
+ * removed rather than skipped.
18
+ */
19
+ export interface MirrorTarget {
20
+ /** Write it, whatever "it" looks like locally. Called once per product. */
21
+ upsert(input: {
22
+ id: string;
23
+ product: ProductDetail;
24
+ }): void | Promise<void>;
25
+ /**
26
+ * Take it out of the mirror.
27
+ *
28
+ * Called for a `product.deleted` delivery and, just as importantly, for a
29
+ * product that no longer answers — a seller who moved something back to draft
30
+ * produces no deletion event, because nothing was deleted.
31
+ */
32
+ remove(input: {
33
+ id: string;
34
+ }): void | Promise<void>;
35
+ /**
36
+ * Every id currently held in the mirror.
37
+ *
38
+ * Only consulted on a full resynchronisation, and only to work out what to
39
+ * take away. Leave it out and a resync will add and update but never remove,
40
+ * which is a reasonable choice for a store where deletions are handled by
41
+ * hand — and a bad surprise if you expected otherwise, hence this note.
42
+ */
43
+ listMirroredIds?(): string[] | Promise<string[]>;
44
+ }
45
+ export interface MirrorReport {
46
+ /** Ids written. */
47
+ upserted: string[];
48
+ /** Ids taken out. */
49
+ removed: string[];
50
+ /**
51
+ * Ids that could not be fetched, and are therefore neither.
52
+ *
53
+ * A rate limit, a five-hundred, a key revoked mid-run. They are reported
54
+ * rather than thrown so that the rest of the batch still lands — but an empty
55
+ * array is the only outcome that means "this mirror is now correct". Anything
56
+ * else wants a retry, and the ids to retry are right here.
57
+ */
58
+ failed: string[];
59
+ /** True when the whole catalogue was walked instead of the named ids. */
60
+ resynchronised: boolean;
61
+ }
62
+ export interface CatalogueMirrorOptions {
63
+ client: CrmClient;
64
+ target: MirrorTarget;
65
+ /** How many products to ask for per page during a resync. */
66
+ pageSize?: number;
67
+ }
68
+ export interface CatalogueMirror {
69
+ /** Act on one verified delivery. Events about other subjects do nothing. */
70
+ apply(payload: ApiWebhookPayload): Promise<MirrorReport>;
71
+ /** Walk the whole catalogue. Safe to run on a schedule or by hand. */
72
+ syncAll(): Promise<MirrorReport>;
73
+ }
74
+ /**
75
+ * Keeps a local copy of the catalogue in step with the one over the wire.
76
+ *
77
+ * A delivery names ids and nothing else, on purpose, so this is where the
78
+ * fetching happens — and that turns out to be the right place for three
79
+ * decisions that are easy to get subtly wrong:
80
+ *
81
+ * - **A product that answers `null` is removed, not skipped.** The only events
82
+ * are "changed" and "deleted", and moving something back to draft is neither:
83
+ * it produces a `product.changed` for an id that then refuses to answer. A
84
+ * mirror that skips it keeps an unpublished product on the shop's shelves.
85
+ * - **`truncated` means walk everything.** It is set when a burst of changes
86
+ * overflowed the batch, so the id list is a fragment and acting on only those
87
+ * ids would leave the rest quietly stale. The flag is read as `=== true`
88
+ * rather than for truthiness, because an older sender that omits it must not
89
+ * be read as "the list is complete".
90
+ * - **Deliveries arrive more than once and out of order.** Both operations here
91
+ * are write-the-current-state rather than apply-a-difference, so a repeat
92
+ * costs a fetch and changes nothing, and a late message still lands on the
93
+ * present truth rather than on a stale snapshot.
94
+ */
95
+ export declare function createCatalogueMirror(options: CatalogueMirrorOptions): CatalogueMirror;
package/dist/mirror.js ADDED
@@ -0,0 +1,102 @@
1
+ // src/mirror.ts
2
+ var DEFAULT_PAGE_SIZE = 50;
3
+ var PRODUCT_CEILING = 1e5;
4
+ function createCatalogueMirror(options) {
5
+ const pageSize = options.pageSize ?? DEFAULT_PAGE_SIZE;
6
+ async function pull(ids) {
7
+ const upserted = [];
8
+ const removed = [];
9
+ const failed = [];
10
+ for (const id of ids) {
11
+ let product;
12
+ try {
13
+ product = await options.client.getProduct(id);
14
+ } catch {
15
+ failed.push(id);
16
+ continue;
17
+ }
18
+ if (product === null) {
19
+ await options.target.remove({ id });
20
+ removed.push(id);
21
+ continue;
22
+ }
23
+ await options.target.upsert({ id, product });
24
+ upserted.push(id);
25
+ }
26
+ return { upserted, removed, failed };
27
+ }
28
+ async function syncAll() {
29
+ const seen = [];
30
+ const positions = /* @__PURE__ */ new Set();
31
+ let cursor = null;
32
+ do {
33
+ const page = await options.client.getProducts({
34
+ limit: pageSize,
35
+ cursor
36
+ });
37
+ for (const item of page.data) {
38
+ seen.push(item.id);
39
+ }
40
+ cursor = page.nextCursor;
41
+ if (cursor !== null) {
42
+ if (positions.has(cursor)) {
43
+ throw new Error(
44
+ "crm-client: the catalogue handed back a cursor it had already given, so the walk is not advancing. Nothing was removed."
45
+ );
46
+ }
47
+ positions.add(cursor);
48
+ }
49
+ if (seen.length > PRODUCT_CEILING) {
50
+ throw new Error(
51
+ `crm-client: more than ${PRODUCT_CEILING} products were walked without reaching the end of the catalogue. Nothing was removed.`
52
+ );
53
+ }
54
+ } while (cursor);
55
+ const pulled = await pull(seen);
56
+ const removed = [...pulled.removed];
57
+ const held = pulled.failed.length ? void 0 : await options.target.listMirroredIds?.();
58
+ if (held) {
59
+ const present = new Set(seen);
60
+ for (const id of held) {
61
+ if (!present.has(id)) {
62
+ await options.target.remove({ id });
63
+ removed.push(id);
64
+ }
65
+ }
66
+ }
67
+ return {
68
+ upserted: pulled.upserted,
69
+ removed,
70
+ failed: pulled.failed,
71
+ resynchronised: true
72
+ };
73
+ }
74
+ return {
75
+ syncAll,
76
+ async apply(payload) {
77
+ if (!payload.type.startsWith("product.")) {
78
+ return { upserted: [], removed: [], failed: [], resynchronised: false };
79
+ }
80
+ if (payload.truncated === true) {
81
+ return syncAll();
82
+ }
83
+ if (payload.type === "product.deleted") {
84
+ for (const id of payload.ids) {
85
+ await options.target.remove({ id });
86
+ }
87
+ return {
88
+ upserted: [],
89
+ removed: [...payload.ids],
90
+ failed: [],
91
+ resynchronised: false
92
+ };
93
+ }
94
+ const pulled = await pull(payload.ids);
95
+ return { ...pulled, resynchronised: false };
96
+ }
97
+ };
98
+ }
99
+ export {
100
+ createCatalogueMirror
101
+ };
102
+ //# sourceMappingURL=mirror.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/mirror.ts"],"sourcesContent":["import type { CrmClient } from './client';\nimport type { ApiWebhookPayload, ProductDetail } from './types';\n\nconst DEFAULT_PAGE_SIZE = 50;\n/**\n * Where a walk stops believing itself.\n *\n * Not a limit on how large a catalogue may be -- it is set far past any real\n * one on purpose. Reaching it means the listing is not ending, which is a fault\n * and not a big shop, and the difference decides what happens: a fault throws.\n */\nconst PRODUCT_CEILING = 100_000;\n\n/**\n * Where a mirrored catalogue is kept, in whatever vocabulary that store uses.\n *\n * This is the one part a site has to write, and it is deliberately the only\n * part. The shape of the local collection is not a detail this package could\n * guess: a Payload shop built on the ecommerce plugin stores `title`,\n * `priceInEUR` and a `gallery` of uploads, the catalogue here answers `name`,\n * `priceFromCents` and `images` of URLs, and no mapping between the two is\n * general — the next shop names them differently again, or keeps only three of\n * them, or has no images at all.\n *\n * What *is* general is everything around it, and that is what this file owns:\n * which ids a delivery actually changed, what to do when the id list came back\n * truncated, and the fact that a product which has stopped answering has to be\n * removed rather than skipped.\n */\nexport interface MirrorTarget {\n /** Write it, whatever \"it\" looks like locally. Called once per product. */\n upsert(input: { id: string; product: ProductDetail }): void | Promise<void>;\n /**\n * Take it out of the mirror.\n *\n * Called for a `product.deleted` delivery and, just as importantly, for a\n * product that no longer answers — a seller who moved something back to draft\n * produces no deletion event, because nothing was deleted.\n */\n remove(input: { id: string }): void | Promise<void>;\n /**\n * Every id currently held in the mirror.\n *\n * Only consulted on a full resynchronisation, and only to work out what to\n * take away. Leave it out and a resync will add and update but never remove,\n * which is a reasonable choice for a store where deletions are handled by\n * hand — and a bad surprise if you expected otherwise, hence this note.\n */\n listMirroredIds?(): string[] | Promise<string[]>;\n}\n\nexport interface MirrorReport {\n /** Ids written. */\n upserted: string[];\n /** Ids taken out. */\n removed: string[];\n /**\n * Ids that could not be fetched, and are therefore neither.\n *\n * A rate limit, a five-hundred, a key revoked mid-run. They are reported\n * rather than thrown so that the rest of the batch still lands — but an empty\n * array is the only outcome that means \"this mirror is now correct\". Anything\n * else wants a retry, and the ids to retry are right here.\n */\n failed: string[];\n /** True when the whole catalogue was walked instead of the named ids. */\n resynchronised: boolean;\n}\n\nexport interface CatalogueMirrorOptions {\n client: CrmClient;\n target: MirrorTarget;\n /** How many products to ask for per page during a resync. */\n pageSize?: number;\n}\n\nexport interface CatalogueMirror {\n /** Act on one verified delivery. Events about other subjects do nothing. */\n apply(payload: ApiWebhookPayload): Promise<MirrorReport>;\n /** Walk the whole catalogue. Safe to run on a schedule or by hand. */\n syncAll(): Promise<MirrorReport>;\n}\n\n/**\n * Keeps a local copy of the catalogue in step with the one over the wire.\n *\n * A delivery names ids and nothing else, on purpose, so this is where the\n * fetching happens — and that turns out to be the right place for three\n * decisions that are easy to get subtly wrong:\n *\n * - **A product that answers `null` is removed, not skipped.** The only events\n * are \"changed\" and \"deleted\", and moving something back to draft is neither:\n * it produces a `product.changed` for an id that then refuses to answer. A\n * mirror that skips it keeps an unpublished product on the shop's shelves.\n * - **`truncated` means walk everything.** It is set when a burst of changes\n * overflowed the batch, so the id list is a fragment and acting on only those\n * ids would leave the rest quietly stale. The flag is read as `=== true`\n * rather than for truthiness, because an older sender that omits it must not\n * be read as \"the list is complete\".\n * - **Deliveries arrive more than once and out of order.** Both operations here\n * are write-the-current-state rather than apply-a-difference, so a repeat\n * costs a fetch and changes nothing, and a late message still lands on the\n * present truth rather than on a stale snapshot.\n */\nexport function createCatalogueMirror(\n options: CatalogueMirrorOptions,\n): CatalogueMirror {\n const pageSize = options.pageSize ?? DEFAULT_PAGE_SIZE;\n\n /**\n * Fetches each id and writes what it finds, and does not let one bad id take\n * the rest of the batch with it.\n *\n * `getProduct` answers `null` for something that is gone and throws for\n * everything else — a rate limit, a five-hundred, a key that was revoked\n * between two calls. Letting that throw escape would abort the loop partway\n * with the ids already written thrown away, and the caller would learn only\n * that \"it failed\": not which items landed, and not which one was the poison.\n * A retry then starts from the top and starves everything after the bad id\n * again, on every attempt, until the underlying fault clears.\n *\n * So a failure is collected rather than thrown, and it travels in the report.\n * What the caller does about it is theirs to decide — but they can only\n * decide if they are told.\n */\n async function pull(ids: readonly string[]) {\n const upserted: string[] = [];\n const removed: string[] = [];\n const failed: string[] = [];\n\n for (const id of ids) {\n let product: ProductDetail | null;\n\n try {\n product = await options.client.getProduct(id);\n } catch {\n failed.push(id);\n\n continue;\n }\n\n if (product === null) {\n await options.target.remove({ id });\n removed.push(id);\n\n continue;\n }\n\n await options.target.upsert({ id, product });\n upserted.push(id);\n }\n\n return { upserted, removed, failed };\n }\n\n /**\n * Walks the whole catalogue, and refuses to pretend it did when it did not.\n *\n * The two guards below both stop the loop, and it matters a great deal that\n * they stop it by throwing. A partial walk that returned normally would go on\n * to the removal step with an incomplete `seen`, and take out every product\n * past the point it stopped — reported as a successful resynchronisation. On\n * a large catalogue that is most of a shop, deleted quietly, by the routine\n * whose job is to keep it correct.\n *\n * A repeated cursor is the precise signature of the thing the ceiling was\n * really there for: a listing that hands back the same position for ever.\n * The ceiling stays as well, for the case where the cursor advances and the\n * end never comes, and it is set where no real catalogue reaches it — it says\n * \"something is wrong\", not \"this shop is too big\".\n */\n async function syncAll(): Promise<MirrorReport> {\n const seen: string[] = [];\n const positions = new Set<string>();\n\n let cursor: string | null = null;\n\n do {\n const page = await options.client.getProducts({\n limit: pageSize,\n cursor,\n });\n\n for (const item of page.data) {\n seen.push(item.id);\n }\n\n cursor = page.nextCursor;\n\n if (cursor !== null) {\n if (positions.has(cursor)) {\n throw new Error(\n 'crm-client: the catalogue handed back a cursor it had already ' +\n 'given, so the walk is not advancing. Nothing was removed.',\n );\n }\n\n positions.add(cursor);\n }\n\n if (seen.length > PRODUCT_CEILING) {\n throw new Error(\n `crm-client: more than ${PRODUCT_CEILING} products were walked ` +\n 'without reaching the end of the catalogue. Nothing was removed.',\n );\n }\n } while (cursor);\n\n const pulled = await pull(seen);\n const removed = [...pulled.removed];\n\n /*\n * The removal step is skipped entirely when anything failed to fetch, for\n * the same reason an unfinished walk throws: \"what the catalogue no longer\n * has\" is worked out by subtracting what was seen from what is held, and a\n * subtraction with a hole in the first set takes out live products. A\n * mirror that is behind is a nuisance; a mirror that deleted half a shop\n * because of a rate limit is not.\n */\n const held = pulled.failed.length\n ? undefined\n : await options.target.listMirroredIds?.();\n\n if (held) {\n const present = new Set(seen);\n\n for (const id of held) {\n if (!present.has(id)) {\n await options.target.remove({ id });\n removed.push(id);\n }\n }\n }\n\n return {\n upserted: pulled.upserted,\n removed,\n failed: pulled.failed,\n resynchronised: true,\n };\n }\n\n return {\n syncAll,\n\n async apply(payload) {\n if (!payload.type.startsWith('product.')) {\n return { upserted: [], removed: [], failed: [], resynchronised: false };\n }\n\n if (payload.truncated === true) {\n return syncAll();\n }\n\n if (payload.type === 'product.deleted') {\n for (const id of payload.ids) {\n await options.target.remove({ id });\n }\n\n /*\n * Removed without fetching, and that is deliberate rather than an\n * oversight of the \"always write current state\" rule the rest of this\n * file follows. `product.deleted` fires on a real row deletion and ids\n * are uuids, so the id in this message can never name something live\n * again -- a fetch here could only ever answer `null` and then remove\n * it anyway, at the cost of a request per id. Archiving something takes\n * the other path: it is an update, so it arrives as `product.changed`,\n * stops answering, and is removed by `pull` above.\n */\n return {\n upserted: [],\n removed: [...payload.ids],\n failed: [],\n resynchronised: false,\n };\n }\n\n const pulled = await pull(payload.ids);\n\n return { ...pulled, resynchronised: false };\n },\n };\n}\n"],"mappings":";AAGA,IAAM,oBAAoB;AAQ1B,IAAM,kBAAkB;AA6FjB,SAAS,sBACd,SACiB;AACjB,QAAM,WAAW,QAAQ,YAAY;AAkBrC,iBAAe,KAAK,KAAwB;AAC1C,UAAM,WAAqB,CAAC;AAC5B,UAAM,UAAoB,CAAC;AAC3B,UAAM,SAAmB,CAAC;AAE1B,eAAW,MAAM,KAAK;AACpB,UAAI;AAEJ,UAAI;AACF,kBAAU,MAAM,QAAQ,OAAO,WAAW,EAAE;AAAA,MAC9C,QAAQ;AACN,eAAO,KAAK,EAAE;AAEd;AAAA,MACF;AAEA,UAAI,YAAY,MAAM;AACpB,cAAM,QAAQ,OAAO,OAAO,EAAE,GAAG,CAAC;AAClC,gBAAQ,KAAK,EAAE;AAEf;AAAA,MACF;AAEA,YAAM,QAAQ,OAAO,OAAO,EAAE,IAAI,QAAQ,CAAC;AAC3C,eAAS,KAAK,EAAE;AAAA,IAClB;AAEA,WAAO,EAAE,UAAU,SAAS,OAAO;AAAA,EACrC;AAkBA,iBAAe,UAAiC;AAC9C,UAAM,OAAiB,CAAC;AACxB,UAAM,YAAY,oBAAI,IAAY;AAElC,QAAI,SAAwB;AAE5B,OAAG;AACD,YAAM,OAAO,MAAM,QAAQ,OAAO,YAAY;AAAA,QAC5C,OAAO;AAAA,QACP;AAAA,MACF,CAAC;AAED,iBAAW,QAAQ,KAAK,MAAM;AAC5B,aAAK,KAAK,KAAK,EAAE;AAAA,MACnB;AAEA,eAAS,KAAK;AAEd,UAAI,WAAW,MAAM;AACnB,YAAI,UAAU,IAAI,MAAM,GAAG;AACzB,gBAAM,IAAI;AAAA,YACR;AAAA,UAEF;AAAA,QACF;AAEA,kBAAU,IAAI,MAAM;AAAA,MACtB;AAEA,UAAI,KAAK,SAAS,iBAAiB;AACjC,cAAM,IAAI;AAAA,UACR,yBAAyB,eAAe;AAAA,QAE1C;AAAA,MACF;AAAA,IACF,SAAS;AAET,UAAM,SAAS,MAAM,KAAK,IAAI;AAC9B,UAAM,UAAU,CAAC,GAAG,OAAO,OAAO;AAUlC,UAAM,OAAO,OAAO,OAAO,SACvB,SACA,MAAM,QAAQ,OAAO,kBAAkB;AAE3C,QAAI,MAAM;AACR,YAAM,UAAU,IAAI,IAAI,IAAI;AAE5B,iBAAW,MAAM,MAAM;AACrB,YAAI,CAAC,QAAQ,IAAI,EAAE,GAAG;AACpB,gBAAM,QAAQ,OAAO,OAAO,EAAE,GAAG,CAAC;AAClC,kBAAQ,KAAK,EAAE;AAAA,QACjB;AAAA,MACF;AAAA,IACF;AAEA,WAAO;AAAA,MACL,UAAU,OAAO;AAAA,MACjB;AAAA,MACA,QAAQ,OAAO;AAAA,MACf,gBAAgB;AAAA,IAClB;AAAA,EACF;AAEA,SAAO;AAAA,IACL;AAAA,IAEA,MAAM,MAAM,SAAS;AACnB,UAAI,CAAC,QAAQ,KAAK,WAAW,UAAU,GAAG;AACxC,eAAO,EAAE,UAAU,CAAC,GAAG,SAAS,CAAC,GAAG,QAAQ,CAAC,GAAG,gBAAgB,MAAM;AAAA,MACxE;AAEA,UAAI,QAAQ,cAAc,MAAM;AAC9B,eAAO,QAAQ;AAAA,MACjB;AAEA,UAAI,QAAQ,SAAS,mBAAmB;AACtC,mBAAW,MAAM,QAAQ,KAAK;AAC5B,gBAAM,QAAQ,OAAO,OAAO,EAAE,GAAG,CAAC;AAAA,QACpC;AAYA,eAAO;AAAA,UACL,UAAU,CAAC;AAAA,UACX,SAAS,CAAC,GAAG,QAAQ,GAAG;AAAA,UACxB,QAAQ,CAAC;AAAA,UACT,gBAAgB;AAAA,QAClB;AAAA,MACF;AAEA,YAAM,SAAS,MAAM,KAAK,QAAQ,GAAG;AAErC,aAAO,EAAE,GAAG,QAAQ,gBAAgB,MAAM;AAAA,IAC5C;AAAA,EACF;AACF;","names":[]}
@@ -0,0 +1,18 @@
1
+ import { CrmApiError } from './errors';
2
+ export declare function readJson(response: Response): Promise<unknown>;
3
+ /**
4
+ * A refusal, whatever shape it arrived in.
5
+ *
6
+ * Almost everything answers `{ error: { code, message } }`, and then the code
7
+ * is taken at its word — it is a closed set this package generates from the
8
+ * server's own list, so a code that does not appear here means the two have
9
+ * drifted and a build somewhere should already have gone red.
10
+ *
11
+ * The events listing is the exception: it answers `{ error: "..." }`, because
12
+ * it predates the shared error helper and is read by script tags on sites
13
+ * nobody here can redeploy. For that one the status code is all there is, and
14
+ * the one place it cannot be precise is 403, where "your key lacks this scope"
15
+ * and "that module is switched off for this account" share a number. It is
16
+ * reported as `forbidden`, the one of the two a developer can act on.
17
+ */
18
+ export declare function refusalOf(status: number, body: unknown, requestUrl: string): CrmApiError;
package/dist/tags.d.ts ADDED
@@ -0,0 +1,18 @@
1
+ /** Everything that lists products. Cleared by every catalogue delivery. */
2
+ export declare function productsTag(): string;
3
+ /** One product, by whichever key it was fetched with. */
4
+ export declare function productTag(slugOrId: string): string;
5
+ export declare function eventsTag(): string;
6
+ export declare function eventTag(idOrSlug: string): string;
7
+ /**
8
+ * What a delivery of this type should clear.
9
+ *
10
+ * `truncated` means the batch stopped collecting and `ids` is incomplete, so
11
+ * the only honest answer is the whole collection — which the collection tag
12
+ * already is. The per-item tags are added on top when the list can be trusted.
13
+ */
14
+ export declare function tagsForDelivery(input: {
15
+ type: string;
16
+ ids: readonly string[];
17
+ truncated: boolean;
18
+ }): string[];
@@ -0,0 +1,172 @@
1
+ import type { ApiErrorCode, PublicEventDetailItem, PublicEventListItem, PublicProductDetail, PublicProductImage, PublicProductListItem } from './generated/api-types';
2
+ export type { ApiErrorCode, ApiScope, ApiWebhookEvent, ApiWebhookPayload, PublicProductCategory, PublicProductPrice, PublicProductSummary, } from './generated/api-types';
3
+ export { ALL_API_ERROR_CODES, ALL_API_SCOPES, ALL_API_WEBHOOK_EVENTS, } from './generated/api-types';
4
+ /**
5
+ * The difference between what the projection holds and what goes over the wire.
6
+ *
7
+ * The services store a storage *path* for every image, because only the route
8
+ * knows which origin its own storage is served from — so the route swaps the
9
+ * path for a finished URL on the way out. These three types are that swap,
10
+ * written as a subtraction from the generated shape rather than as a fresh
11
+ * list, so a field added to the projection lands here without anybody noticing
12
+ * it had to.
13
+ */
14
+ export interface ProductListItem extends Omit<PublicProductListItem, 'imagePath'> {
15
+ /** Ready to put in an `<img>`; null when the seller uploaded no picture. */
16
+ imageUrl: string | null;
17
+ }
18
+ export interface ProductDetail extends Omit<PublicProductDetail, 'imagePath' | 'images'> {
19
+ imageUrl: string | null;
20
+ images: ProductImage[];
21
+ }
22
+ export interface ProductImage extends Omit<PublicProductImage, 'path'> {
23
+ url: string;
24
+ }
25
+ export interface EventListItem extends Omit<PublicEventListItem, 'coverImagePath'> {
26
+ coverImageUrl: string | null;
27
+ }
28
+ export interface EventDetail extends Omit<PublicEventDetailItem, 'coverImagePath' | 'url'> {
29
+ coverImageUrl: string | null;
30
+ /** Null when the event has no page on the hosted site to point at. */
31
+ url: string | null;
32
+ }
33
+ /** What a listing answers: a page of items and the way to ask for the next. */
34
+ export interface ProductList {
35
+ data: ProductListItem[];
36
+ /** Null on the last page. Pass it back as `cursor` for the one after this. */
37
+ nextCursor: string | null;
38
+ }
39
+ /**
40
+ * The events listing, which does not have the shape of the others.
41
+ *
42
+ * It answered `{ account, events, nextCursor }` years before this API had a key
43
+ * or a convention, and script tags on sites nobody here can redeploy read those
44
+ * exact names. Renaming them to match would break every one of them at once, so
45
+ * the older shape stays and this type says so out loud rather than hiding the
46
+ * difference behind a translation.
47
+ */
48
+ export interface EventList {
49
+ account: {
50
+ name: string;
51
+ slug: string;
52
+ } | null;
53
+ events: EventListItem[];
54
+ nextCursor: string | null;
55
+ }
56
+ /** Whether a visitor can buy it right now. Never how many are left. */
57
+ export interface StockStatus {
58
+ id: string;
59
+ slug: string;
60
+ inStock: boolean;
61
+ }
62
+ /** Seats, live. `remaining` is null when the evening has no ceiling. */
63
+ export interface EventAvailability {
64
+ eventId: string;
65
+ remaining: number | null;
66
+ soldOut: boolean;
67
+ ticketTypes: {
68
+ id: string;
69
+ remaining: number | null;
70
+ soldOut: boolean;
71
+ }[];
72
+ }
73
+ export interface OrderBuyer {
74
+ email: string;
75
+ firstName?: string;
76
+ lastName?: string;
77
+ phone?: string;
78
+ company?: string;
79
+ vatNumber?: string;
80
+ }
81
+ export interface OrderAddress {
82
+ line1?: string;
83
+ line2?: string;
84
+ postalCode?: string;
85
+ city?: string;
86
+ /** Two letters, ISO 3166-1 alpha-2. */
87
+ countryCode?: string;
88
+ }
89
+ /**
90
+ * One thing being bought.
91
+ *
92
+ * Name either the product or one of its price options, and a quantity. There is
93
+ * deliberately no amount here and none is accepted: the total is worked out on
94
+ * the server from the seller's own price rows. A shop where the browser may
95
+ * state the price is a shop where the customer types it.
96
+ */
97
+ export interface OrderLine {
98
+ productId?: string;
99
+ priceId?: string;
100
+ quantity?: number;
101
+ }
102
+ export interface OrderInput {
103
+ buyer: OrderBuyer;
104
+ lines: OrderLine[];
105
+ address?: OrderAddress;
106
+ }
107
+ export interface OrderResult {
108
+ id: string;
109
+ number: string;
110
+ status: string;
111
+ currency: string;
112
+ subtotalCents: number;
113
+ taxCents: number;
114
+ totalCents: number;
115
+ contactId: string;
116
+ /**
117
+ * What to hand the payment sheet.
118
+ *
119
+ * `stripeAccount` is the seller's own connected account — the shop's checkout
120
+ * has to be initialised against it, or the intent will not be found.
121
+ */
122
+ payment: {
123
+ kind: string;
124
+ clientSecret: string;
125
+ stripeAccount: string;
126
+ amountCents: number;
127
+ };
128
+ }
129
+ export interface ContactInput {
130
+ email: string;
131
+ firstName?: string;
132
+ lastName?: string;
133
+ phone?: string;
134
+ company?: string;
135
+ }
136
+ export interface ContactResult {
137
+ id: string;
138
+ /** False when the address matched somebody already known. */
139
+ created: boolean;
140
+ }
141
+ export interface RequestInput {
142
+ contact: ContactInput;
143
+ message: string;
144
+ productId?: string;
145
+ source?: string;
146
+ }
147
+ export interface RequestResult {
148
+ id: string;
149
+ contactId: string;
150
+ contactCreated: boolean;
151
+ }
152
+ export interface FormSubmissionInput {
153
+ fields: Record<string, unknown>;
154
+ sourceUrl?: string;
155
+ }
156
+ export interface FormSubmissionResult {
157
+ id: string | null;
158
+ duplicate: boolean;
159
+ status: string | null;
160
+ contactId: string | null;
161
+ }
162
+ /** The body of every refusal. `fields` is present only on a schema complaint. */
163
+ export interface ApiErrorBody {
164
+ error: {
165
+ code: ApiErrorCode;
166
+ message: string;
167
+ fields?: {
168
+ path: string;
169
+ message: string;
170
+ }[];
171
+ };
172
+ }