astroidjs 0.1.1 → 0.2.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/README.md +240 -5
- package/bin/astroid.mjs +185 -9
- package/dist/analytics/index.d.ts +37 -0
- package/dist/analytics/index.js +108 -0
- package/dist/astro/csp.d.ts +64 -0
- package/dist/astro/csp.js +173 -0
- package/dist/astro/index.d.ts +1 -0
- package/dist/astro/index.js +7 -0
- package/dist/commerce/adapters.d.ts +60 -0
- package/dist/commerce/adapters.js +90 -0
- package/dist/commerce/checkout-scaffold.d.ts +42 -0
- package/dist/commerce/checkout-scaffold.js +306 -0
- package/dist/commerce/checkout.d.ts +72 -0
- package/dist/commerce/checkout.js +124 -0
- package/dist/commerce/index.d.ts +8 -0
- package/dist/commerce/index.js +9 -0
- package/dist/commerce/loader.d.ts +71 -0
- package/dist/commerce/loader.js +90 -0
- package/dist/commerce/mirror.d.ts +67 -0
- package/dist/commerce/mirror.js +203 -0
- package/dist/commerce/roles.d.ts +38 -0
- package/dist/commerce/roles.js +93 -0
- package/dist/commerce/secrets.d.ts +74 -0
- package/dist/commerce/secrets.js +129 -0
- package/dist/commerce/sync.d.ts +86 -0
- package/dist/commerce/sync.js +154 -0
- package/dist/components/sections.d.ts +577 -0
- package/dist/components/sections.js +425 -0
- package/dist/config.d.ts +174 -12
- package/dist/config.js +43 -1
- package/dist/email/index.d.ts +4 -0
- package/dist/email/index.js +5 -0
- package/dist/email/inquiry.d.ts +33 -0
- package/dist/email/inquiry.js +63 -0
- package/dist/email/send.d.ts +120 -0
- package/dist/email/send.js +196 -0
- package/dist/email/templates.d.ts +24 -0
- package/dist/email/templates.js +184 -0
- package/dist/email/theme.d.ts +24 -0
- package/dist/email/theme.js +150 -0
- package/dist/errors.d.ts +14 -0
- package/dist/errors.js +17 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +14 -0
- package/dist/map/index.d.ts +3 -0
- package/dist/map/index.js +4 -0
- package/dist/map/pmtiles.d.ts +92 -0
- package/dist/map/pmtiles.js +130 -0
- package/dist/map/scaffold.d.ts +29 -0
- package/dist/map/scaffold.js +212 -0
- package/dist/map/style.d.ts +58 -0
- package/dist/map/style.js +154 -0
- package/dist/portal/config.d.ts +26 -0
- package/dist/portal/config.js +50 -0
- package/dist/portal/guard.d.ts +48 -0
- package/dist/portal/guard.js +64 -0
- package/dist/portal/index.d.ts +5 -0
- package/dist/portal/index.js +6 -0
- package/dist/portal/nav.d.ts +26 -0
- package/dist/portal/nav.js +35 -0
- package/dist/portal/scaffold.d.ts +28 -0
- package/dist/portal/scaffold.js +140 -0
- package/dist/portal/session.d.ts +36 -0
- package/dist/portal/session.js +86 -0
- package/dist/portfolio/index.d.ts +1 -0
- package/dist/portfolio/index.js +4 -0
- package/dist/portfolio/scaffold.d.ts +9 -0
- package/dist/portfolio/scaffold.js +93 -0
- package/dist/project/actions.d.ts +3 -0
- package/dist/project/actions.js +106 -0
- package/dist/project/generate.d.ts +15 -0
- package/dist/project/generate.js +144 -2
- package/dist/project/index.d.ts +2 -0
- package/dist/project/index.js +2 -0
- package/dist/project/scaffold.d.ts +29 -0
- package/dist/project/scaffold.js +140 -0
- package/dist/pwa/generate.d.ts +49 -0
- package/dist/pwa/generate.js +218 -0
- package/dist/pwa/index.d.ts +1 -0
- package/dist/pwa/index.js +2 -0
- package/dist/queues/consumer.d.ts +29 -0
- package/dist/queues/consumer.js +37 -0
- package/dist/queues/index.d.ts +4 -0
- package/dist/queues/index.js +5 -0
- package/dist/queues/messages.d.ts +60 -0
- package/dist/queues/messages.js +71 -0
- package/dist/queues/scaffold.d.ts +44 -0
- package/dist/queues/scaffold.js +204 -0
- package/dist/queues/webhook.d.ts +60 -0
- package/dist/queues/webhook.js +81 -0
- package/dist/realtime/index.d.ts +1 -0
- package/dist/realtime/index.js +4 -0
- package/dist/realtime/scaffold.d.ts +30 -0
- package/dist/realtime/scaffold.js +159 -0
- package/dist/schema/collections.d.ts +42 -8
- package/dist/schema/collections.js +102 -8
- package/dist/schema/generate.js +10 -1
- package/dist/secrets.d.ts +54 -0
- package/dist/secrets.js +80 -0
- package/dist/security/index.d.ts +1 -0
- package/dist/security/index.js +2 -0
- package/dist/security/rate-rules.d.ts +21 -0
- package/dist/security/rate-rules.js +107 -0
- package/dist/seo/index.d.ts +3 -0
- package/dist/seo/index.js +4 -0
- package/dist/seo/resolve.d.ts +68 -0
- package/dist/seo/resolve.js +73 -0
- package/dist/seo/routes.d.ts +44 -0
- package/dist/seo/routes.js +104 -0
- package/dist/seo/structured-data.d.ts +51 -0
- package/dist/seo/structured-data.js +105 -0
- package/dist/status.d.ts +51 -0
- package/dist/status.js +113 -0
- package/dist/worker/generate.d.ts +18 -10
- package/dist/worker/generate.js +325 -37
- package/dist/worker/routes.d.ts +1 -1
- package/dist/worker/routes.js +42 -0
- package/dist/workflow/advance.d.ts +102 -0
- package/dist/workflow/advance.js +145 -0
- package/dist/workflow/config.d.ts +60 -0
- package/dist/workflow/config.js +73 -0
- package/dist/workflow/generate.d.ts +22 -0
- package/dist/workflow/generate.js +138 -0
- package/dist/workflow/index.d.ts +3 -0
- package/dist/workflow/index.js +4 -0
- package/package.json +21 -5
- package/src/components/Editable.astro +33 -9
- package/src/components/JustifiedGallery.astro +254 -0
- package/src/components/MediaSlot.astro +178 -0
- package/src/components/PortalShell.astro +80 -0
- package/src/components/RegisterSW.astro +45 -0
- package/src/components/Section.astro +101 -35
- package/src/components/Sections.astro +64 -0
- package/src/components/Seo.astro +57 -0
- package/src/components/StageBar.astro +137 -0
- package/src/components/StructuredData.astro +33 -0
- package/src/components/justify.ts +170 -0
- package/src/components/media-meta.ts +174 -0
- package/src/components/sections/AboutIntro.astro +46 -0
- package/src/components/sections/Banner.astro +31 -0
- package/src/components/sections/Contact.astro +22 -9
- package/src/components/sections/Cta.astro +33 -10
- package/src/components/sections/Faq.astro +50 -0
- package/src/components/sections/FeatureGrid.astro +40 -11
- package/src/components/sections/Gallery.astro +46 -0
- package/src/components/sections/Hero.astro +40 -12
- package/src/components/sections/LocationHours.astro +59 -0
- package/src/components/sections/Media.astro +44 -0
- package/src/components/sections/PricingTiers.astro +79 -0
- package/src/components/sections/ProductGrid.astro +73 -0
- package/src/components/sections/SplitImage.astro +61 -0
- package/src/components/sections/Steps.astro +58 -0
- package/src/components/sections/Testimonial.astro +51 -0
- package/src/components/sections.ts +452 -67
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
|
|
2
|
+
//
|
|
3
|
+
// What each commerce provider needs before it can do anything, and the gate
|
|
4
|
+
// derived from it.
|
|
5
|
+
//
|
|
6
|
+
// The rest of this module is deliberately credential-free — the adapters are
|
|
7
|
+
// pure, the mirror and the loader only touch D1, and `verifyCheckout` takes a
|
|
8
|
+
// price lookup rather than a token. That's the right shape: it keeps the parts
|
|
9
|
+
// testable and lets the caller own how the fetch happens.
|
|
10
|
+
//
|
|
11
|
+
// But something still has to answer "is commerce actually on?", and every
|
|
12
|
+
// consuming site answered it by hand, differently. Squaring that away here is
|
|
13
|
+
// the commerce half of the dormant-until-provisioned convention (#252): the
|
|
14
|
+
// names below are the single source of truth for what a provider requires, so
|
|
15
|
+
// the wrangler generator can seed them, `astroid doctor` can report them, and a
|
|
16
|
+
// storefront can decide between a live catalog and a simulated one — all from
|
|
17
|
+
// one declaration instead of three hand-maintained lists.
|
|
18
|
+
//
|
|
19
|
+
// The webhook secret lives here too, rather than beside the verifier in
|
|
20
|
+
// `queues/scaffold.ts`, because it is the same fact: a provider's secret set.
|
|
21
|
+
// The scaffold imports it from here so the two can't drift.
|
|
22
|
+
import { resolveModuleSecrets } from "../secrets.js";
|
|
23
|
+
import { astroidCommerceProviders, astroidCommerceRoles } from "./roles.js";
|
|
24
|
+
/**
|
|
25
|
+
* Per-provider secret names, split by what they gate.
|
|
26
|
+
*
|
|
27
|
+
* `credentials` is what the provider's `louise-toolkit/commerce/*` client needs
|
|
28
|
+
* to make a call at all; `webhook` is the signing secret its receiver verifies
|
|
29
|
+
* with. They're separable on purpose — a site can receive verified webhooks
|
|
30
|
+
* before it has finished provisioning API access, and the reverse is the normal
|
|
31
|
+
* state of a brand-new integration.
|
|
32
|
+
*
|
|
33
|
+
* Names match what the scaffolded `env.d.ts` declares, so a project can read
|
|
34
|
+
* `env.SQUARE_ACCESS_TOKEN` and have it typed.
|
|
35
|
+
*/
|
|
36
|
+
export const COMMERCE_PROVIDER_SECRETS = {
|
|
37
|
+
// The location id is not a secret, but it IS required: Square's orders and
|
|
38
|
+
// payments endpoints refuse a request without one, so a token on its own
|
|
39
|
+
// leaves checkout broken rather than dormant. Requiring both is what makes
|
|
40
|
+
// "configured" mean "can actually take money".
|
|
41
|
+
square: {
|
|
42
|
+
credentials: ["SQUARE_ACCESS_TOKEN", "SQUARE_LOCATION_ID"],
|
|
43
|
+
webhook: "SQUARE_WEBHOOK_SECRET",
|
|
44
|
+
},
|
|
45
|
+
stripe: {
|
|
46
|
+
credentials: ["STRIPE_SECRET_KEY"],
|
|
47
|
+
webhook: "STRIPE_WEBHOOK_SECRET",
|
|
48
|
+
},
|
|
49
|
+
// Fourthwall's storefront token is public-safe (it's sent as a query param
|
|
50
|
+
// from the browser in their own SDK), but it is still provisioned, so it
|
|
51
|
+
// follows the same gate — an absent token means no catalog.
|
|
52
|
+
fourthwall: {
|
|
53
|
+
credentials: ["FOURTHWALL_STOREFRONT_TOKEN"],
|
|
54
|
+
webhook: "FOURTHWALL_WEBHOOK_SECRET",
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Where a developer actually gets each provider's credentials.
|
|
59
|
+
*
|
|
60
|
+
* Carried next to the names because the scaffold's job is to leave someone able
|
|
61
|
+
* to finish provisioning without a search: a `.env.example` line that says
|
|
62
|
+
* `SQUARE_ACCESS_TOKEN=DUMMY_REPLACE_ME` and nothing else has told them what to
|
|
63
|
+
* do, only that something is missing.
|
|
64
|
+
*/
|
|
65
|
+
export const COMMERCE_PROVIDER_SETUP = {
|
|
66
|
+
square: "developer.squareup.com → your app → Credentials (access token) and Locations (location id). Webhook secret: the same app → Webhooks → Subscriptions.",
|
|
67
|
+
stripe: "dashboard.stripe.com → Developers → API keys (secret key). Webhook secret: Developers → Webhooks → your endpoint → Signing secret.",
|
|
68
|
+
fourthwall: "Fourthwall dashboard → Settings → For developers → Storefront token. Webhook secret: the same page → Webhooks.",
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* Every secret name this project's commerce configuration needs, deduplicated
|
|
72
|
+
* and in a stable order.
|
|
73
|
+
*
|
|
74
|
+
* Deduplication is the point: a site running Square in both roles has one
|
|
75
|
+
* access token, not two, and seeding `SQUARE_ACCESS_TOKEN` twice into a
|
|
76
|
+
* `.dev.vars` would be a bug rather than a redundancy.
|
|
77
|
+
*/
|
|
78
|
+
export function commerceSecretNames(commerce) {
|
|
79
|
+
const names = astroidCommerceProviders(commerce).flatMap((provider) => [
|
|
80
|
+
...COMMERCE_PROVIDER_SECRETS[provider].credentials,
|
|
81
|
+
COMMERCE_PROVIDER_SECRETS[provider].webhook,
|
|
82
|
+
]);
|
|
83
|
+
return [...new Set(names)];
|
|
84
|
+
}
|
|
85
|
+
/** Read one provider's secrets off an env-shaped record. */
|
|
86
|
+
async function resolveProvider(provider, roles, env) {
|
|
87
|
+
const spec = COMMERCE_PROVIDER_SECRETS[provider];
|
|
88
|
+
const pick = (names) => Object.fromEntries(names.map((n) => [n, env[n]]));
|
|
89
|
+
const [credentials, webhook] = await Promise.all([
|
|
90
|
+
resolveModuleSecrets(pick(spec.credentials)),
|
|
91
|
+
resolveModuleSecrets(pick([spec.webhook])),
|
|
92
|
+
]);
|
|
93
|
+
return {
|
|
94
|
+
provider,
|
|
95
|
+
roles,
|
|
96
|
+
credentials,
|
|
97
|
+
webhook,
|
|
98
|
+
configured: credentials.configured && webhook.configured,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Resolve the commerce module's dormancy from the runtime env.
|
|
103
|
+
*
|
|
104
|
+
* ```ts
|
|
105
|
+
* const status = await resolveCommerceStatus(config.commerce, env);
|
|
106
|
+
* const products = status.configured
|
|
107
|
+
* ? await syncThenRead(env) // live
|
|
108
|
+
* : await readCatalog({ db: env.DB, table }); // whatever the mirror already holds
|
|
109
|
+
* ```
|
|
110
|
+
*
|
|
111
|
+
* Note what "dormant" means for commerce specifically: the D1 mirror is still
|
|
112
|
+
* readable, so an unprovisioned storefront serves the catalog it last synced
|
|
113
|
+
* (usually the seeded sample rows) rather than an error page. That is the whole
|
|
114
|
+
* reason the mirror exists as a separate layer from the provider client.
|
|
115
|
+
*/
|
|
116
|
+
export async function resolveCommerceStatus(commerce, env) {
|
|
117
|
+
const roles = astroidCommerceRoles(commerce);
|
|
118
|
+
const providers = astroidCommerceProviders(commerce);
|
|
119
|
+
if (providers.length === 0) {
|
|
120
|
+
return { configured: false, enabled: false, providers: [], missing: [] };
|
|
121
|
+
}
|
|
122
|
+
const resolved = await Promise.all(providers.map((provider) => resolveProvider(provider, ["storefront", "invoicing"].filter((r) => roles[r] === provider), env)));
|
|
123
|
+
return {
|
|
124
|
+
configured: resolved.every((p) => p.configured),
|
|
125
|
+
enabled: true,
|
|
126
|
+
providers: resolved,
|
|
127
|
+
missing: resolved.flatMap((p) => [...p.credentials.missing, ...p.webhook.missing]),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/** The provider-agnostic shape an adapter normalizes a product into. */
|
|
2
|
+
export interface CatalogItem {
|
|
3
|
+
/** The provider's id. Unique, stable, and the sync's idempotency key. */
|
|
4
|
+
externalId: string;
|
|
5
|
+
name: string;
|
|
6
|
+
/** Major units (dollars), matching the mirror's `price` column. */
|
|
7
|
+
price: number;
|
|
8
|
+
images?: string[];
|
|
9
|
+
variants?: unknown;
|
|
10
|
+
/** The provider's own slug, when it has one — for re-fetching a single item. */
|
|
11
|
+
externalSlug?: string;
|
|
12
|
+
}
|
|
13
|
+
/** The D1 surface the sync needs. Structural, so a real `D1Database` fits. */
|
|
14
|
+
export interface SyncDatabase {
|
|
15
|
+
prepare(query: string): {
|
|
16
|
+
bind(...values: unknown[]): {
|
|
17
|
+
run(): Promise<unknown>;
|
|
18
|
+
first<T = Record<string, unknown>>(): Promise<T | null>;
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export interface CatalogSyncOptions {
|
|
23
|
+
db: SyncDatabase;
|
|
24
|
+
/** Mirror table name — must match the generated schema. */
|
|
25
|
+
table: string;
|
|
26
|
+
/**
|
|
27
|
+
* `overlay` mode writes only the key and `synced_at`: the catalog fields live
|
|
28
|
+
* at the provider, and there are no pulled columns to write.
|
|
29
|
+
*/
|
|
30
|
+
mode?: "mirror" | "overlay";
|
|
31
|
+
/** Build the public slug for a NEW row. Defaults to a slugified name. */
|
|
32
|
+
slugify?: (item: CatalogItem) => string;
|
|
33
|
+
}
|
|
34
|
+
export interface CatalogSyncResult {
|
|
35
|
+
/** Rows created — new products, landing as `draft`. */
|
|
36
|
+
created: number;
|
|
37
|
+
/** Rows whose pulled fields were refreshed. */
|
|
38
|
+
updated: number;
|
|
39
|
+
/** Items that threw and were skipped. The next run retries them. */
|
|
40
|
+
failed: number;
|
|
41
|
+
/**
|
|
42
|
+
* One error per failed item, in order, for logging.
|
|
43
|
+
*
|
|
44
|
+
* Capped — a catalog-wide failure would otherwise build an array as long as
|
|
45
|
+
* the catalog just to describe the same fault N times.
|
|
46
|
+
*/
|
|
47
|
+
errors: {
|
|
48
|
+
externalId: string;
|
|
49
|
+
message: string;
|
|
50
|
+
}[];
|
|
51
|
+
}
|
|
52
|
+
/** Lowercase, dash-separated, punctuation stripped. */
|
|
53
|
+
export declare function defaultSlug(value: string): string;
|
|
54
|
+
/**
|
|
55
|
+
* Upsert one item. Returns whether it created a row.
|
|
56
|
+
*
|
|
57
|
+
* The UPDATE branch lists pulled columns ONLY — see the note at the top of this
|
|
58
|
+
* file. In `overlay` mode there are no pulled columns, so an existing row is
|
|
59
|
+
* touched only to stamp `synced_at`.
|
|
60
|
+
*/
|
|
61
|
+
export declare function astroidCatalogUpsert(item: CatalogItem, options: CatalogSyncOptions): Promise<{
|
|
62
|
+
created: boolean;
|
|
63
|
+
}>;
|
|
64
|
+
/**
|
|
65
|
+
* Sync a whole catalog snapshot.
|
|
66
|
+
*
|
|
67
|
+
* Items are written one at a time and a single failure doesn't abandon the rest:
|
|
68
|
+
* a partial catalog is strictly better than a stale one, and the next cron tick
|
|
69
|
+
* retries whatever didn't land. Rows whose product has vanished upstream are
|
|
70
|
+
* left alone — deciding whether a missing item is delisted or just a failed page
|
|
71
|
+
* of an API response is the project's call, not the sync's, and unpublishing
|
|
72
|
+
* someone's whole catalog on a bad response is unrecoverable.
|
|
73
|
+
*
|
|
74
|
+
* **A TOTAL failure throws.** Tolerating individual items is the point; silently
|
|
75
|
+
* reporting `{ created: 0, updated: 0 }` when every write failed is not. The
|
|
76
|
+
* result carried no failure count, so an unapplied migration or an unavailable
|
|
77
|
+
* D1 looked exactly like an empty catalog: the queue consumer acked the message,
|
|
78
|
+
* the cron re-sync acked too, and the site served a frozen catalog indefinitely
|
|
79
|
+
* with nothing in `wrangler tail`. Throwing when nothing at all landed is what
|
|
80
|
+
* makes the queue's retry and DLQ do their job.
|
|
81
|
+
*
|
|
82
|
+
* Partial failures do NOT throw — they come back in `failed`/`errors` for the
|
|
83
|
+
* caller to log, because retrying the whole batch to re-attempt a few rows would
|
|
84
|
+
* undo the tolerance this loop exists to provide.
|
|
85
|
+
*/
|
|
86
|
+
export declare function astroidCatalogSync(items: CatalogItem[], options: CatalogSyncOptions): Promise<CatalogSyncResult>;
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
|
|
2
|
+
//
|
|
3
|
+
// The sync: pull the provider's catalog, write it into the mirror without
|
|
4
|
+
// touching a single thing the owner edited.
|
|
5
|
+
//
|
|
6
|
+
// Two invariants do all the work here.
|
|
7
|
+
//
|
|
8
|
+
// **Idempotency.** The sync runs from a cron AND from webhooks, so the same
|
|
9
|
+
// product arrives repeatedly and sometimes concurrently. Keying every write on
|
|
10
|
+
// the provider's own id (unique in the table) means a re-run is a no-op update
|
|
11
|
+
// rather than a duplicate row.
|
|
12
|
+
//
|
|
13
|
+
// **The owned set is never in an UPDATE.** Not "usually not" — never. A sync
|
|
14
|
+
// that writes an owned column is a sync that silently reverts the owner's work,
|
|
15
|
+
// and they find out days later when someone notices the description is back to
|
|
16
|
+
// the vendor's default. So the update statement is built from the pulled set
|
|
17
|
+
// alone, and owned columns only appear in the INSERT, as defaults for a row
|
|
18
|
+
// that didn't exist yet.
|
|
19
|
+
import { AstroidUsageError } from "../errors.js";
|
|
20
|
+
/** Lowercase, dash-separated, punctuation stripped. */
|
|
21
|
+
export function defaultSlug(value) {
|
|
22
|
+
return (value
|
|
23
|
+
.normalize("NFKD")
|
|
24
|
+
.replace(/[̀-ͯ]/g, "")
|
|
25
|
+
.toLowerCase()
|
|
26
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
27
|
+
.replace(/^-+|-+$/g, "")
|
|
28
|
+
.slice(0, 80) || "item");
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Allocate a slug that isn't taken, by suffixing `-2`, `-3`, … Two different
|
|
32
|
+
* provider items genuinely can share a name ("Original" in two collections), and
|
|
33
|
+
* the slug column is unique, so an unguarded insert would fail the whole sync
|
|
34
|
+
* over a naming coincidence.
|
|
35
|
+
*/
|
|
36
|
+
async function allocateSlug(db, table, base, externalId) {
|
|
37
|
+
for (let n = 1; n < 50; n++) {
|
|
38
|
+
const candidate = n === 1 ? base : `${base}-${n}`;
|
|
39
|
+
const clash = await db
|
|
40
|
+
.prepare(`SELECT external_id FROM ${table} WHERE slug = ?`)
|
|
41
|
+
.bind(candidate)
|
|
42
|
+
.first();
|
|
43
|
+
// Free, or already ours (a re-run reaching the same row).
|
|
44
|
+
if (!clash || clash.external_id === externalId)
|
|
45
|
+
return candidate;
|
|
46
|
+
}
|
|
47
|
+
// Pathological: 50 items with one name. Fall back to something unique by
|
|
48
|
+
// construction rather than failing the sync.
|
|
49
|
+
return `${base}-${externalId.slice(-8).toLowerCase()}`;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Upsert one item. Returns whether it created a row.
|
|
53
|
+
*
|
|
54
|
+
* The UPDATE branch lists pulled columns ONLY — see the note at the top of this
|
|
55
|
+
* file. In `overlay` mode there are no pulled columns, so an existing row is
|
|
56
|
+
* touched only to stamp `synced_at`.
|
|
57
|
+
*/
|
|
58
|
+
export async function astroidCatalogUpsert(item, options) {
|
|
59
|
+
const { db, table } = options;
|
|
60
|
+
const mode = options.mode ?? "mirror";
|
|
61
|
+
const now = Math.floor(Date.now() / 1000);
|
|
62
|
+
const existing = await db
|
|
63
|
+
.prepare(`SELECT id FROM ${table} WHERE external_id = ?`)
|
|
64
|
+
.bind(item.externalId)
|
|
65
|
+
.first();
|
|
66
|
+
if (existing) {
|
|
67
|
+
if (mode === "overlay") {
|
|
68
|
+
await db
|
|
69
|
+
.prepare(`UPDATE ${table} SET synced_at = ? WHERE external_id = ?`)
|
|
70
|
+
.bind(now, item.externalId)
|
|
71
|
+
.run();
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
await db
|
|
75
|
+
.prepare(`UPDATE ${table}
|
|
76
|
+
SET name = ?, price = ?, images = ?, variants = ?, external_slug = ?, synced_at = ?
|
|
77
|
+
WHERE external_id = ?`)
|
|
78
|
+
.bind(item.name, item.price, JSON.stringify(item.images ?? []), JSON.stringify(item.variants ?? null), item.externalSlug ?? null, now, item.externalId)
|
|
79
|
+
.run();
|
|
80
|
+
}
|
|
81
|
+
return { created: false };
|
|
82
|
+
}
|
|
83
|
+
const slug = await allocateSlug(db, table, options.slugify ? options.slugify(item) : defaultSlug(item.name), item.externalId);
|
|
84
|
+
if (mode === "overlay") {
|
|
85
|
+
await db
|
|
86
|
+
.prepare(`INSERT INTO ${table} (external_id, slug, synced_at) VALUES (?, ?, ?)`)
|
|
87
|
+
.bind(item.externalId, slug, now)
|
|
88
|
+
.run();
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
await db
|
|
92
|
+
.prepare(`INSERT INTO ${table}
|
|
93
|
+
(external_id, name, price, images, variants, external_slug, synced_at, slug)
|
|
94
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
95
|
+
.bind(item.externalId, item.name, item.price, JSON.stringify(item.images ?? []), JSON.stringify(item.variants ?? null), item.externalSlug ?? null, now, slug)
|
|
96
|
+
.run();
|
|
97
|
+
}
|
|
98
|
+
return { created: true };
|
|
99
|
+
}
|
|
100
|
+
/** How many per-item errors to keep. Enough to spot a pattern, bounded so a
|
|
101
|
+
* catalog-wide fault doesn't allocate one message per product. */
|
|
102
|
+
const MAX_RECORDED_ERRORS = 10;
|
|
103
|
+
/**
|
|
104
|
+
* Sync a whole catalog snapshot.
|
|
105
|
+
*
|
|
106
|
+
* Items are written one at a time and a single failure doesn't abandon the rest:
|
|
107
|
+
* a partial catalog is strictly better than a stale one, and the next cron tick
|
|
108
|
+
* retries whatever didn't land. Rows whose product has vanished upstream are
|
|
109
|
+
* left alone — deciding whether a missing item is delisted or just a failed page
|
|
110
|
+
* of an API response is the project's call, not the sync's, and unpublishing
|
|
111
|
+
* someone's whole catalog on a bad response is unrecoverable.
|
|
112
|
+
*
|
|
113
|
+
* **A TOTAL failure throws.** Tolerating individual items is the point; silently
|
|
114
|
+
* reporting `{ created: 0, updated: 0 }` when every write failed is not. The
|
|
115
|
+
* result carried no failure count, so an unapplied migration or an unavailable
|
|
116
|
+
* D1 looked exactly like an empty catalog: the queue consumer acked the message,
|
|
117
|
+
* the cron re-sync acked too, and the site served a frozen catalog indefinitely
|
|
118
|
+
* with nothing in `wrangler tail`. Throwing when nothing at all landed is what
|
|
119
|
+
* makes the queue's retry and DLQ do their job.
|
|
120
|
+
*
|
|
121
|
+
* Partial failures do NOT throw — they come back in `failed`/`errors` for the
|
|
122
|
+
* caller to log, because retrying the whole batch to re-attempt a few rows would
|
|
123
|
+
* undo the tolerance this loop exists to provide.
|
|
124
|
+
*/
|
|
125
|
+
export async function astroidCatalogSync(items, options) {
|
|
126
|
+
const result = { created: 0, updated: 0, failed: 0, errors: [] };
|
|
127
|
+
for (const item of items) {
|
|
128
|
+
try {
|
|
129
|
+
const { created } = await astroidCatalogUpsert(item, options);
|
|
130
|
+
if (created)
|
|
131
|
+
result.created++;
|
|
132
|
+
else
|
|
133
|
+
result.updated++;
|
|
134
|
+
}
|
|
135
|
+
catch (cause) {
|
|
136
|
+
// Skip this item; the next run retries it — but record it, so "nothing
|
|
137
|
+
// synced" can be told apart from "nothing to sync".
|
|
138
|
+
result.failed++;
|
|
139
|
+
if (result.errors.length < MAX_RECORDED_ERRORS) {
|
|
140
|
+
result.errors.push({
|
|
141
|
+
externalId: item.externalId,
|
|
142
|
+
message: cause instanceof Error ? cause.message : String(cause),
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (items.length > 0 && result.failed === items.length) {
|
|
148
|
+
throw new AstroidUsageError(`Catalog sync failed for all ${items.length} item(s) — nothing was written. ` +
|
|
149
|
+
`First error: ${result.errors[0]?.message ?? "unknown"}. ` +
|
|
150
|
+
"This is usually an unapplied migration (the catalog table doesn't exist yet) " +
|
|
151
|
+
"or an unavailable D1 binding.");
|
|
152
|
+
}
|
|
153
|
+
return result;
|
|
154
|
+
}
|