@storystick/client 0.0.0-stage → 0.2.0-rc.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.
Files changed (75) hide show
  1. package/LICENSE +112 -0
  2. package/PUBLIC-API.md +22 -0
  3. package/README.md +180 -2
  4. package/dist/.source-hash +1 -0
  5. package/dist/_fetchers.d.ts +51 -0
  6. package/dist/_fetchers.js +144 -0
  7. package/dist/_stateMachine.d.ts +102 -0
  8. package/dist/_stateMachine.js +168 -0
  9. package/dist/_wireAuth.d.ts +48 -0
  10. package/dist/_wireAuth.js +52 -0
  11. package/dist/adapter.d.ts +14 -0
  12. package/dist/adapter.js +10 -0
  13. package/dist/content-values.d.ts +11 -0
  14. package/dist/content-values.js +401 -0
  15. package/dist/errors.d.ts +65 -0
  16. package/dist/errors.js +45 -0
  17. package/dist/hydrate-slice.d.ts +2 -0
  18. package/dist/hydrate-slice.js +1 -0
  19. package/dist/index.d.ts +23 -0
  20. package/dist/index.js +9 -0
  21. package/dist/infra-resolver.d.ts +94 -0
  22. package/dist/infra-resolver.js +130 -0
  23. package/dist/internal.d.ts +33 -0
  24. package/dist/internal.js +20 -0
  25. package/dist/locale-routing.d.ts +5 -0
  26. package/dist/locale-routing.js +33 -0
  27. package/dist/normalize-pathname.d.ts +79 -0
  28. package/dist/normalize-pathname.js +103 -0
  29. package/dist/preview-frame-dependencies.d.ts +39 -0
  30. package/dist/preview-frame-dependencies.js +84 -0
  31. package/dist/preview-query-receipt.d.ts +3 -0
  32. package/dist/preview-query-receipt.js +16 -0
  33. package/dist/preview-read-lifecycle.d.ts +10 -0
  34. package/dist/preview-read-lifecycle.js +40 -0
  35. package/dist/preview-read-observation.d.ts +18 -0
  36. package/dist/preview-read-observation.js +81 -0
  37. package/dist/preview-scope.d.ts +3 -0
  38. package/dist/preview-scope.js +22 -0
  39. package/dist/preview-target.d.ts +73 -0
  40. package/dist/preview-target.js +86 -0
  41. package/dist/project-locales.d.ts +4 -0
  42. package/dist/project-locales.js +12 -0
  43. package/dist/public-delivery.d.ts +36 -0
  44. package/dist/public-delivery.js +55 -0
  45. package/dist/published-routes.d.ts +3 -0
  46. package/dist/published-routes.js +100 -0
  47. package/dist/query-builder.d.ts +225 -0
  48. package/dist/query-builder.js +294 -0
  49. package/dist/query-client.d.ts +54 -0
  50. package/dist/query-client.js +306 -0
  51. package/dist/query-compiler.d.ts +5 -0
  52. package/dist/query-compiler.js +12 -0
  53. package/dist/query-schema.d.ts +32 -0
  54. package/dist/query-schema.js +1 -0
  55. package/dist/redirect-search.d.ts +4 -0
  56. package/dist/redirect-search.js +14 -0
  57. package/dist/reference-resolver.d.ts +37 -0
  58. package/dist/reference-resolver.js +126 -0
  59. package/dist/resolve-route.d.ts +27 -0
  60. package/dist/resolve-route.js +145 -0
  61. package/dist/resolve.d.ts +43 -0
  62. package/dist/resolve.js +160 -0
  63. package/dist/schemas.d.ts +71 -0
  64. package/dist/schemas.js +75 -0
  65. package/dist/seo.d.ts +52 -0
  66. package/dist/seo.js +93 -0
  67. package/dist/signed-webhook.d.ts +13 -0
  68. package/dist/signed-webhook.js +54 -0
  69. package/dist/transport.d.ts +29 -0
  70. package/dist/transport.js +584 -0
  71. package/dist/typed-route.d.ts +22 -0
  72. package/dist/typed-route.js +1 -0
  73. package/dist/types.d.ts +147 -0
  74. package/dist/types.js +1 -0
  75. package/package.json +70 -3
@@ -0,0 +1,145 @@
1
+ /**
2
+ * `resolveStorystickPage` — core URL → document resolver (ADR-094 §1).
3
+ *
4
+ * Takes a configured client and a pathname (and optional storytype filter),
5
+ * dispatches a SINGLE call to the backend `queries:getRouteByPathname`
6
+ * cross-storytype endpoint (Lane A0), and returns a discriminated
7
+ * {@link ResolvedRoute}.
8
+ *
9
+ * Single-call semantics: the backend resolves cross-storytype via the
10
+ * `by_projectId_resolvedPathname` index (ADR-086 URL-uniqueness invariant);
11
+ * no SDK-side fan-out across storytypes. 1 indexed call per invocation,
12
+ * regardless of how many storytypes the project declares.
13
+ *
14
+ * Production vs preview parity is JWT-driven — the configured client's
15
+ * `requestContext.previewToken` (when set) routes the underlying transport
16
+ * to the preview JWT; otherwise the API-key mint cache supplies the
17
+ * production JWT (audit P1-1).
18
+ */
19
+ import { attachDocumentLocaleRouting, publicHrefForLocale, resolveLocalePathname } from './locale-routing.js';
20
+ import { getInternalClientRuntime } from './query-client.js';
21
+ import { normalizePathname } from './normalize-pathname.js';
22
+ import { attachPublicRouteIdentity } from './public-delivery.js';
23
+ import { hasUsablePreviewToken, withPreviewVariantArgs } from './preview-scope.js';
24
+ import { attachPreviewFrameDependencies, previewAssetDependenciesFromValue, } from './preview-frame-dependencies.js';
25
+ import { createPreviewProvenanceReadCacheKey, runPreviewProvenanceReadLifecycle, } from './preview-read-lifecycle.js';
26
+ /**
27
+ * Resolve a developer-supplied pathname to a `ResolvedRoute` discriminated
28
+ * union. Throws on infrastructure failures (auth, network, gateway 5xx) —
29
+ * the return shape only models *resolution outcomes*, never failures
30
+ * (per ADR-094 §1 error strategy).
31
+ */
32
+ export async function resolveStorystickPage(client, input) {
33
+ // ADR-094 §1 storyTypes semantics — empty array is rejected as
34
+ // INVALID_ARGS. The almost-always-a-bug case (developer forgot to
35
+ // populate the list) gets a clear error rather than silently
36
+ // returning notFound.
37
+ if (input.storyTypes !== undefined && input.storyTypes.length === 0) {
38
+ throw new Error('StorystickError: resolveStorystickPage was called with `storyTypes: []` ' +
39
+ '(empty array). Empty storyTypes is rejected as INVALID_ARGS — pass a non-empty array ' +
40
+ 'to filter to specific types, or omit the argument entirely to resolve any URL-routable storytype. ' +
41
+ 'See ADR-094 §1 storyTypes semantics.');
42
+ }
43
+ const canonical = normalizePathname(input.pathname);
44
+ const runtime = getInternalClientRuntime(client);
45
+ // Dispatch a single cross-storytype call via the configured client's
46
+ // transport. We don't go through a generated story query because this helper
47
+ // isn't bound to a single storytype — the backend resolves any URL-routable
48
+ // type that matches the canonical pathname (ADR-094 §1.bis).
49
+ const args = { pathname: canonical, ...(input.locale ? { locale: input.locale } : {}) };
50
+ if (input.storyTypes && input.storyTypes.length > 0) {
51
+ args.storyTypes = [...input.storyTypes];
52
+ }
53
+ return runPreviewProvenanceReadLifecycle(runtime.requestContext, {
54
+ kind: 'resolved-route',
55
+ cacheKey: createPreviewProvenanceReadCacheKey('resolved-route', {
56
+ pathname: canonical,
57
+ locale: input.locale,
58
+ publicOrigins: runtime.localeRouting?.publicOrigins,
59
+ storyTypes: input.storyTypes ? [...input.storyTypes].sort() : undefined,
60
+ }),
61
+ executeRaw: async () => {
62
+ const response = await runtime.transport.request({
63
+ udfPath: 'queries:getRouteByPathname',
64
+ args: withPreviewVariantArgs('queries:getRouteByPathname', args, runtime.requestContext),
65
+ options: undefined,
66
+ requestContext: runtime.requestContext,
67
+ signal: undefined,
68
+ });
69
+ const data = response.data;
70
+ if (!data)
71
+ return { type: 'notFound' };
72
+ if ('notFound' in data) {
73
+ return {
74
+ type: 'notFound',
75
+ ...(data.localeContext ? { localeContext: siteLocaleContext(data.localeContext, runtime.localeRouting?.publicOrigins) } : {}),
76
+ };
77
+ }
78
+ if ('redirectTo' in data) {
79
+ let redirectTo = data.redirectTo;
80
+ if (runtime.localeRouting?.publicOrigins && redirectTo.startsWith('/') && !redirectTo.startsWith('//')) {
81
+ const resolved = resolveLocalePathname(redirectTo, runtime.localeRouting);
82
+ if (!resolved.ok)
83
+ throw new TypeError('Invalid published redirect locale.');
84
+ const suffix = redirectTo.match(/[?#].*$/)?.[0] ?? '';
85
+ redirectTo = publicHrefForLocale(resolved.pathname, resolved.locale, runtime.localeRouting) + suffix;
86
+ }
87
+ return {
88
+ type: 'redirect',
89
+ redirectTo,
90
+ permanent: data.permanent === true,
91
+ };
92
+ }
93
+ if (hasUsablePreviewToken(runtime.requestContext)) {
94
+ attachPreviewFrameDependencies(data.document, [
95
+ ...routeDependencies({
96
+ pathname: canonical,
97
+ storyTypes: input.storyTypes,
98
+ document: data.document,
99
+ storyType: data.storyType,
100
+ }),
101
+ ...previewAssetDependenciesFromValue(data.document),
102
+ ]);
103
+ }
104
+ const localeContext = siteLocaleContext(data.localeContext, runtime.localeRouting?.publicOrigins);
105
+ attachDocumentLocaleRouting(data.document, localeContext);
106
+ if (!hasUsablePreviewToken(runtime.requestContext))
107
+ attachPublicRouteIdentity(data.document, response.publicReadIdentity);
108
+ return {
109
+ type: 'document',
110
+ document: data.document,
111
+ storyType: data.storyType,
112
+ canonicalPathname: data.localeContext.pathname,
113
+ publicPathname: localeContext.publicPathname,
114
+ localeContext,
115
+ primaryLocale: data.primaryLocale,
116
+ alternates: data.alternates,
117
+ seo: data.seo ?? { indexable: true },
118
+ };
119
+ },
120
+ });
121
+ }
122
+ function routeDependencies(input) {
123
+ return [
124
+ {
125
+ kind: 'route-lookup',
126
+ pathname: input.pathname,
127
+ ...(input.storyTypes && input.storyTypes.length > 0
128
+ ? { storyTypes: [...input.storyTypes].sort() }
129
+ : {}),
130
+ },
131
+ {
132
+ kind: 'document',
133
+ documentId: input.document.documentId,
134
+ storyType: input.storyType,
135
+ reason: 'route-document',
136
+ },
137
+ ];
138
+ }
139
+ function siteLocaleContext(context, publicOrigins) {
140
+ return {
141
+ ...context,
142
+ ...(publicOrigins ? { publicOrigins } : {}),
143
+ ...(publicOrigins?.[context.locale] ? { publicPathname: '/' + context.pathname.replace(/^\/+/, '') } : {}),
144
+ };
145
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Project shortId resolution
3
+ *
4
+ * Resolves a project shortId (8-char Crockford, e.g. `a3k9m2x7`) to its
5
+ * infrastructure config (endpoint, metadata, plan, features) via the
6
+ * Storystick gateway API.
7
+ *
8
+ * Post ADR-063 (Wave 2): the public project identifier is the shortId.
9
+ * The same identifier appears in URLs (`/p/<label>-<shortId>`), SDK
10
+ * config (`STORYSTICK_PROJECT_ID=<shortId>`), API key prefixes, logs,
11
+ * and support lookups. Previous slug-based config has been removed.
12
+ *
13
+ * Post multi-tenant refactor (ADR-058): the gateway returns `endpoint`
14
+ * (the Storystick gateway URL — same for all tenants) + tenant-scoped
15
+ * metadata. Tenant isolation is carried by a JWT claim that the gateway
16
+ * mints from the caller's API key server-side — the SDK does not need
17
+ * to know the internal Convex id.
18
+ *
19
+ * Used by:
20
+ * - generated clients that need endpoint discovery
21
+ * - @storystick/next server helpers (server-side, cached in memory)
22
+ */
23
+ export interface ProjectConfig {
24
+ /** Storystick gateway endpoint.
25
+ * Same URL for every project on this Storystick deployment. */
26
+ readonly endpoint: string;
27
+ /** Public project identifier — the 8-char Crockford shortId that
28
+ * appears in URLs, SDK env config, API key prefixes, logs. */
29
+ readonly projectId: string;
30
+ readonly name: string;
31
+ readonly plan: string;
32
+ readonly features: Readonly<Record<string, boolean>>;
33
+ }
34
+ /**
35
+ * Resolve a project shortId to its config.
36
+ *
37
+ * Client-side: caches in sessionStorage (survives page navigations).
38
+ * Server-side: caches in module-level Map (expires after five minutes).
39
+ *
40
+ * const config = await resolveProject('a3k9m2x7')
41
+ * // → { endpoint: 'https://...', projectId: 'a3k9m2x7', plan: 'free', ... }
42
+ */
43
+ export declare function resolveProject(projectId: string, gatewayUrl?: string): Promise<ProjectConfig>;
@@ -0,0 +1,160 @@
1
+ import { STORYSTICK_GATEWAY_ORIGIN } from '@storystick/types/public-endpoints';
2
+ // ─────────────────────────────────────────────
3
+ // Constants
4
+ // ─────────────────────────────────────────────
5
+ const DEFAULT_GATEWAY_URL = STORYSTICK_GATEWAY_ORIGIN;
6
+ const PROJECT_CACHE_PREFIX = 'storystick:project:v1:';
7
+ const PROJECT_CACHE_TTL_MS = 5 * 60 * 1000;
8
+ // Module-level cache for server-side (bounded and refreshed after five minutes)
9
+ const PROJECT_CACHE_MAX_ENTRIES = 128;
10
+ const _cache = new Map();
11
+ const inFlight = new Map();
12
+ // ─────────────────────────────────────────────
13
+ // Resolution
14
+ // ─────────────────────────────────────────────
15
+ /**
16
+ * Resolve a project shortId to its config.
17
+ *
18
+ * Client-side: caches in sessionStorage (survives page navigations).
19
+ * Server-side: caches in module-level Map (expires after five minutes).
20
+ *
21
+ * const config = await resolveProject('a3k9m2x7')
22
+ * // → { endpoint: 'https://...', projectId: 'a3k9m2x7', plan: 'free', ... }
23
+ */
24
+ export async function resolveProject(projectId, gatewayUrl) {
25
+ const gateway = gatewayUrl ?? DEFAULT_GATEWAY_URL;
26
+ // ADR-131 C9 codex R3 P2 fix : the cache key must include the gateway
27
+ // URL so that a self-host customer passing a non-default `gatewayUrl`
28
+ // does not get the default-gateway-resolved config back from the cache.
29
+ // The same shortId resolved through different gateways yields different
30
+ // endpoint/plan/features rows.
31
+ const cacheKey = `${projectId}::${gateway}`;
32
+ const cached = getFromCache(cacheKey, projectId);
33
+ if (cached)
34
+ return cached;
35
+ const pending = inFlight.get(cacheKey);
36
+ if (pending)
37
+ return pending;
38
+ const request = fetchProject(projectId, gateway, cacheKey);
39
+ // Bound bookkeeping even when a discovery endpoint is unavailable.
40
+ if (inFlight.size < PROJECT_CACHE_MAX_ENTRIES)
41
+ inFlight.set(cacheKey, request);
42
+ try {
43
+ return await request;
44
+ }
45
+ finally {
46
+ if (inFlight.get(cacheKey) === request)
47
+ inFlight.delete(cacheKey);
48
+ }
49
+ }
50
+ async function fetchProject(projectId, gateway, cacheKey) {
51
+ const url = `${gateway}/api/projects/${encodeURIComponent(projectId)}/config`;
52
+ const response = await fetch(url);
53
+ if (!response.ok) {
54
+ if (response.status === 404) {
55
+ const body = await response.json().catch(() => ({}));
56
+ throw new Error(body.message ??
57
+ `StorystickError: Project "${projectId}" not found.\n` +
58
+ `Check your STORYSTICK_PROJECT_ID environment variable.\n`);
59
+ }
60
+ if (response.status === 503) {
61
+ const body = await response.json().catch(() => ({}));
62
+ throw new Error(body.message ??
63
+ `StorystickError: Project "${projectId}" has no backend provisioned.\n`);
64
+ }
65
+ throw new Error(`StorystickError: Failed to resolve project "${projectId}" (HTTP ${response.status})`);
66
+ }
67
+ // Gateway returns { endpoint, projectId: <convex _id>, name, shortId,
68
+ // urlLabel, plan, features }. Post-Wave-2 the SDK treats the shortId
69
+ // as the public projectId; the Convex `_id` stays hidden inside the
70
+ // gateway (used only for JWT minting + internal FK integrity).
71
+ const raw = await response.json();
72
+ if (!isRecord(raw))
73
+ throw new Error('StorystickError: Invalid project discovery response.');
74
+ const config = {
75
+ endpoint: raw.endpoint,
76
+ projectId: raw.shortId,
77
+ name: raw.name,
78
+ plan: raw.plan,
79
+ features: raw.features,
80
+ };
81
+ if (!isProjectConfig(config, projectId)) {
82
+ throw new Error('StorystickError: Invalid project discovery response.');
83
+ }
84
+ Object.freeze(config.features);
85
+ Object.freeze(config);
86
+ // Store in cache (keyed by projectId + gateway URL — see cacheKey above)
87
+ setInCache(cacheKey, config);
88
+ return config;
89
+ }
90
+ // ─────────────────────────────────────────────
91
+ // Caching
92
+ // ─────────────────────────────────────────────
93
+ function getFromCache(cacheKey, projectId) {
94
+ // Server-side: module-level Map
95
+ if (typeof window === 'undefined') {
96
+ const entry = _cache.get(cacheKey);
97
+ if (!entry)
98
+ return null;
99
+ const age = Date.now() - entry.timestamp;
100
+ if (age < 0 || age >= PROJECT_CACHE_TTL_MS) {
101
+ _cache.delete(cacheKey);
102
+ return null;
103
+ }
104
+ // Least recently used entries are evicted first.
105
+ _cache.delete(cacheKey);
106
+ _cache.set(cacheKey, entry);
107
+ return entry.config;
108
+ }
109
+ // Client-side: sessionStorage
110
+ try {
111
+ const raw = sessionStorage.getItem(`${PROJECT_CACHE_PREFIX}${cacheKey}`);
112
+ if (!raw)
113
+ return null;
114
+ const parsed = JSON.parse(raw);
115
+ if (!isRecord(parsed))
116
+ return null;
117
+ const { config, timestamp } = parsed;
118
+ if (typeof timestamp !== 'number' ||
119
+ !Number.isFinite(timestamp) ||
120
+ timestamp > Date.now() ||
121
+ Date.now() - timestamp >= PROJECT_CACHE_TTL_MS ||
122
+ !isProjectConfig(config, projectId))
123
+ return null;
124
+ Object.freeze(config.features);
125
+ return Object.freeze(config);
126
+ }
127
+ catch {
128
+ return null;
129
+ }
130
+ }
131
+ function setInCache(cacheKey, config) {
132
+ // Server-side
133
+ if (typeof window === 'undefined') {
134
+ _cache.delete(cacheKey);
135
+ _cache.set(cacheKey, { config, timestamp: Date.now() });
136
+ while (_cache.size > PROJECT_CACHE_MAX_ENTRIES)
137
+ _cache.delete(_cache.keys().next().value);
138
+ return;
139
+ }
140
+ // Client-side
141
+ try {
142
+ sessionStorage.setItem(`${PROJECT_CACHE_PREFIX}${cacheKey}`, JSON.stringify({ config, timestamp: Date.now() }));
143
+ }
144
+ catch {
145
+ // sessionStorage might be unavailable (private browsing, etc.)
146
+ }
147
+ }
148
+ function isRecord(value) {
149
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
150
+ }
151
+ function isProjectConfig(value, projectId) {
152
+ if (!isRecord(value) || !isRecord(value.features))
153
+ return false;
154
+ return typeof value.endpoint === 'string' &&
155
+ value.endpoint.length > 0 &&
156
+ value.projectId === projectId &&
157
+ typeof value.name === 'string' &&
158
+ typeof value.plan === 'string' &&
159
+ Object.values(value.features).every((feature) => typeof feature === 'boolean');
160
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Schema delivery — ADR-131 Amendment 002.
3
+ *
4
+ * Standalone wrapper around the existing `/api/q/schemas/current` HTTP
5
+ * route (`apps/studio/convex/gateway_queries.ts::schemasCurrent`). Auth
6
+ * comes from `client.requestContext.previewToken` in preview mode (the
7
+ * iframe runtime path) ; production callers use the same configured
8
+ * client and inherit the API-key mint chain.
9
+ *
10
+ * NOT exposed as `client.schemas.current()`. The client factory's Proxy
11
+ * treats unknown string keys as story types (see `index.ts:198`) ;
12
+ * adding `schemas` as a first-class namespace would require modifying
13
+ * the surface/type/metadata assembly. The standalone-function pattern
14
+ * sidesteps that and matches the existing convention for non-storytype
15
+ * SDK calls.
16
+ */
17
+ import type { Client } from './query-client.js';
18
+ import type { RequestOptions } from './types.js';
19
+ import { STORY_SCHEMA_FORMAT_VERSION } from '@storystick/types';
20
+ import { StorystickError } from './errors.js';
21
+ export declare const SCHEMA_FORMAT_UNSUPPORTED_CODE: "storystick/SCHEMA_FORMAT_UNSUPPORTED";
22
+ export interface SchemaFormatUnsupportedPayload {
23
+ expectedFormatVersion: typeof STORY_SCHEMA_FORMAT_VERSION;
24
+ receivedFormatVersion: number | null;
25
+ }
26
+ export declare function isSchemaFormatUnsupportedError(error: unknown): error is StorystickError & {
27
+ readonly code: typeof SCHEMA_FORMAT_UNSUPPORTED_CODE;
28
+ readonly payload: SchemaFormatUnsupportedPayload;
29
+ };
30
+ /**
31
+ * Shape returned by `GET /api/q/schemas/current`. For an installed
32
+ * generation this is its row in the `schemas` Convex table. A verified,
33
+ * not-yet-installed B Preview uses the same envelope with `pushedAt: 0`.
34
+ *
35
+ * The route returns the full row — the iframe schema-store keeps it
36
+ * verbatim to preserve fidelity for future tooling ; downstream consumers
37
+ * (resolver §D6.1, render-for-correlation) extract the fields they need.
38
+ *
39
+ * `null` when no schema has been pushed yet for the tenant.
40
+ *
41
+ * Additional Convex metadata (`_id`, `_creationTime`) may be present on
42
+ * the row at runtime ; the interface omits them for clarity. Callers
43
+ * that need them can read via `(row as { _id?: string })._id`.
44
+ */
45
+ export interface RawSchemaRow {
46
+ projectId: string;
47
+ formatVersion: typeof STORY_SCHEMA_FORMAT_VERSION;
48
+ version: string;
49
+ stories: unknown;
50
+ storyCount: number;
51
+ zoneMappings?: unknown;
52
+ fieldBindings?: unknown;
53
+ diagnostics?: unknown;
54
+ pushedAt: number;
55
+ }
56
+ /**
57
+ * Fetch the schema paired to this client's signed build. Multi-tenant
58
+ * scoping is enforced by `gatewayTenantQuery`; the JWT's `projectId` is
59
+ * the structural cross-tenant boundary.
60
+ *
61
+ * Returns `null` if no schema has been pushed for the tenant.
62
+ *
63
+ * Wire path (ADR-131 Amendment 002 §3.1) :
64
+ * client.transport.request({ udfPath: 'queries:schemas.current', ... })
65
+ * → endpointForUdf('queries:schemas.current')
66
+ * → GET /api/q/schemas/current
67
+ * → authAndLimit (preview JWT OR API-key mint)
68
+ * → gateway_queries::schemasCurrent
69
+ * → signed generation resolution → exact schema row (or prepared target)
70
+ */
71
+ export declare function fetchCurrentSchema(client: Client, options?: RequestOptions): Promise<RawSchemaRow | null>;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Schema delivery — ADR-131 Amendment 002.
3
+ *
4
+ * Standalone wrapper around the existing `/api/q/schemas/current` HTTP
5
+ * route (`apps/studio/convex/gateway_queries.ts::schemasCurrent`). Auth
6
+ * comes from `client.requestContext.previewToken` in preview mode (the
7
+ * iframe runtime path) ; production callers use the same configured
8
+ * client and inherit the API-key mint chain.
9
+ *
10
+ * NOT exposed as `client.schemas.current()`. The client factory's Proxy
11
+ * treats unknown string keys as story types (see `index.ts:198`) ;
12
+ * adding `schemas` as a first-class namespace would require modifying
13
+ * the surface/type/metadata assembly. The standalone-function pattern
14
+ * sidesteps that and matches the existing convention for non-storytype
15
+ * SDK calls.
16
+ */
17
+ import { STORY_SCHEMA_FORMAT_VERSION } from '@storystick/types';
18
+ import { StorystickError } from './errors.js';
19
+ import { getInternalClientRuntime } from './query-client.js';
20
+ export const SCHEMA_FORMAT_UNSUPPORTED_CODE = 'storystick/SCHEMA_FORMAT_UNSUPPORTED';
21
+ export function isSchemaFormatUnsupportedError(error) {
22
+ return error instanceof StorystickError &&
23
+ error.code === SCHEMA_FORMAT_UNSUPPORTED_CODE;
24
+ }
25
+ /**
26
+ * Fetch the schema paired to this client's signed build. Multi-tenant
27
+ * scoping is enforced by `gatewayTenantQuery`; the JWT's `projectId` is
28
+ * the structural cross-tenant boundary.
29
+ *
30
+ * Returns `null` if no schema has been pushed for the tenant.
31
+ *
32
+ * Wire path (ADR-131 Amendment 002 §3.1) :
33
+ * client.transport.request({ udfPath: 'queries:schemas.current', ... })
34
+ * → endpointForUdf('queries:schemas.current')
35
+ * → GET /api/q/schemas/current
36
+ * → authAndLimit (preview JWT OR API-key mint)
37
+ * → gateway_queries::schemasCurrent
38
+ * → signed generation resolution → exact schema row (or prepared target)
39
+ */
40
+ export async function fetchCurrentSchema(client, options) {
41
+ const runtime = getInternalClientRuntime(client);
42
+ const response = await runtime.transport.request({
43
+ udfPath: 'queries:schemas.current',
44
+ args: {},
45
+ options,
46
+ requestContext: runtime.requestContext,
47
+ });
48
+ if (response.data === null)
49
+ return null;
50
+ if (!response.data ||
51
+ typeof response.data !== 'object' ||
52
+ Array.isArray(response.data) ||
53
+ response.data.formatVersion !==
54
+ STORY_SCHEMA_FORMAT_VERSION) {
55
+ const received = response.data !== null &&
56
+ typeof response.data === 'object' &&
57
+ !Array.isArray(response.data) &&
58
+ typeof response.data.formatVersion ===
59
+ 'number'
60
+ ? response.data.formatVersion
61
+ : null;
62
+ throw new StorystickError(`Unsupported Storystick schema format; expected V${STORY_SCHEMA_FORMAT_VERSION}, ` +
63
+ `received ${received === null ? 'a payload without formatVersion' : `V${received}`}.`, {
64
+ code: SCHEMA_FORMAT_UNSUPPORTED_CODE,
65
+ category: 'validation',
66
+ recoverable: true,
67
+ retryable: false,
68
+ payload: {
69
+ expectedFormatVersion: STORY_SCHEMA_FORMAT_VERSION,
70
+ receivedFormatVersion: received,
71
+ },
72
+ });
73
+ }
74
+ return response.data;
75
+ }
package/dist/seo.d.ts ADDED
@@ -0,0 +1,52 @@
1
+ import type { ResolvedRoute } from './index.js';
2
+ import { type PublicLocaleOrigins } from './locale-routing.js';
3
+ type ClientResolvedDocumentRoute = Extract<ResolvedRoute, {
4
+ type: 'document';
5
+ }>;
6
+ export interface ResolvedDocumentRoute {
7
+ readonly localeContext: ClientResolvedDocumentRoute['localeContext'];
8
+ readonly publicPathname: ClientResolvedDocumentRoute['publicPathname'];
9
+ readonly alternates: ClientResolvedDocumentRoute['alternates'];
10
+ readonly primaryLocale: ClientResolvedDocumentRoute['primaryLocale'];
11
+ readonly seo: ClientResolvedDocumentRoute['seo'];
12
+ }
13
+ export interface StorystickAlternatesOptions {
14
+ readonly siteUrl: string | URL;
15
+ readonly publicOrigins?: PublicLocaleOrigins;
16
+ }
17
+ export interface StorystickPublishedLocaleRoute {
18
+ readonly documentId: string;
19
+ readonly storyType: string;
20
+ readonly locale: string;
21
+ readonly pathname: string;
22
+ readonly publicPathname: string;
23
+ readonly defaultLocale?: string;
24
+ readonly primaryLocale?: string;
25
+ readonly seo: ClientResolvedDocumentRoute['seo'];
26
+ }
27
+ export interface StorystickSitemapOptions {
28
+ readonly locale?: string;
29
+ readonly siteUrl: string | URL;
30
+ readonly publicOrigins?: PublicLocaleOrigins;
31
+ }
32
+ export interface StorystickAlternates {
33
+ canonical?: string;
34
+ languages?: Record<string, string>;
35
+ }
36
+ export interface StorystickSitemapEntry {
37
+ url: string;
38
+ alternates?: {
39
+ languages: Record<string, string>;
40
+ };
41
+ }
42
+ export declare const STORYSTICK_SITEMAP_MAX_URLS = 50000;
43
+ /**
44
+ * Convert a publication-backed route map to canonical and language URLs. Missing
45
+ * translations are absent by construction. The publication SEO posture is
46
+ * frozen at publish time so metadata and sitemap share one decision.
47
+ */
48
+ export declare function storystickAlternates(route: ResolvedDocumentRoute, options: StorystickAlternatesOptions): StorystickAlternates | undefined;
49
+ /** Pure mapping used by `ss.sitemap` and pinned independently in unit tests. */
50
+ export declare function buildStorystickSitemap(routes: readonly StorystickPublishedLocaleRoute[], options: StorystickSitemapOptions): StorystickSitemapEntry[];
51
+ export declare function buildStorystickSitemapShards(routes: readonly StorystickPublishedLocaleRoute[], options: StorystickSitemapOptions, shardSize?: number): StorystickSitemapEntry[][];
52
+ export {};
package/dist/seo.js ADDED
@@ -0,0 +1,93 @@
1
+ import { publicHrefForPublication, publishedLocaleHrefs, publicHrefForLocale, normalizePublicLocaleOrigins } from './locale-routing.js';
2
+ export const STORYSTICK_SITEMAP_MAX_URLS = 50_000;
3
+ function absoluteUrl(siteUrl, pathname) {
4
+ const base = new URL(siteUrl);
5
+ if (/^https?:\/\//.test(pathname))
6
+ return new URL(pathname).toString();
7
+ const normalizedPathname = pathname.startsWith('/') ? pathname : `/${pathname}`;
8
+ return new URL(normalizedPathname, base).toString();
9
+ }
10
+ /**
11
+ * Convert a publication-backed route map to canonical and language URLs. Missing
12
+ * translations are absent by construction. The publication SEO posture is
13
+ * frozen at publish time so metadata and sitemap share one decision.
14
+ */
15
+ export function storystickAlternates(route, options) {
16
+ const settings = { ...route.localeContext, ...(options.publicOrigins ? { publicOrigins: normalizePublicLocaleOrigins(options.publicOrigins) } : {}) };
17
+ const currentUrl = () => absoluteUrl(options.siteUrl, publicHrefForLocale(route.localeContext.pathname, route.localeContext.locale, settings));
18
+ if (!route.seo.indexable) {
19
+ return {
20
+ canonical: route.seo.canonicalUrl ??
21
+ currentUrl(),
22
+ };
23
+ }
24
+ if (route.seo.canonicalUrl !== undefined) {
25
+ return { canonical: route.seo.canonicalUrl };
26
+ }
27
+ const publishedLanguages = Object.entries(publishedLocaleHrefs(route.alternates, settings));
28
+ if (publishedLanguages.length === 0)
29
+ return undefined;
30
+ const languages = Object.fromEntries(publishedLanguages.map(([locale, pathname]) => [
31
+ locale,
32
+ absoluteUrl(options.siteUrl, pathname),
33
+ ]));
34
+ const available = new Set(publishedLanguages.map(([locale]) => locale));
35
+ const xDefaultLocale = available.has(route.localeContext.defaultLocale)
36
+ ? route.localeContext.defaultLocale
37
+ : available.has(route.primaryLocale)
38
+ ? route.primaryLocale
39
+ : route.localeContext.locale;
40
+ const xDefaultPathname = languages[xDefaultLocale];
41
+ if (xDefaultPathname) {
42
+ languages['x-default'] = absoluteUrl(options.siteUrl, xDefaultPathname);
43
+ }
44
+ const canonicalPathname = languages[route.localeContext.locale];
45
+ return {
46
+ ...(canonicalPathname
47
+ ? { canonical: absoluteUrl(options.siteUrl, canonicalPathname) }
48
+ : {}),
49
+ languages,
50
+ };
51
+ }
52
+ /** Pure mapping used by `ss.sitemap` and pinned independently in unit tests. */
53
+ export function buildStorystickSitemap(routes, options) {
54
+ const origins = options.publicOrigins ? normalizePublicLocaleOrigins(options.publicOrigins) : undefined;
55
+ const urlFor = (route) => absoluteUrl(options.siteUrl, publicHrefForPublication(route, origins));
56
+ const localRoutes = routes.filter((route) => route.seo.indexable && route.seo.canonicalUrl === undefined);
57
+ const byDocument = new Map();
58
+ for (const route of localRoutes) {
59
+ const cluster = byDocument.get(route.documentId) ?? [];
60
+ cluster.push(route);
61
+ byDocument.set(route.documentId, cluster);
62
+ }
63
+ return localRoutes.filter(route => !options.locale || route.locale === options.locale).map((route) => {
64
+ const cluster = byDocument.get(route.documentId) ?? [route];
65
+ if (cluster.length < 2) {
66
+ return { url: urlFor(route) };
67
+ }
68
+ const languages = Object.fromEntries(cluster.map((alternate) => [
69
+ alternate.locale,
70
+ urlFor(alternate),
71
+ ]));
72
+ const locales = new Set(cluster.map((alternate) => alternate.locale));
73
+ const defaultLocale = cluster.find((alternate) => alternate.defaultLocale !== undefined &&
74
+ locales.has(alternate.defaultLocale))?.defaultLocale;
75
+ const primaryLocale = cluster.find((alternate) => alternate.primaryLocale !== undefined &&
76
+ locales.has(alternate.primaryLocale))?.primaryLocale;
77
+ const xDefaultLocale = defaultLocale ?? primaryLocale ?? cluster[0].locale;
78
+ languages['x-default'] = languages[xDefaultLocale];
79
+ return {
80
+ url: urlFor(route),
81
+ alternates: { languages },
82
+ };
83
+ });
84
+ }
85
+ export function buildStorystickSitemapShards(routes, options, shardSize = STORYSTICK_SITEMAP_MAX_URLS) {
86
+ if (!Number.isSafeInteger(shardSize) || shardSize < 1) {
87
+ throw new RangeError('sitemap shardSize must be a positive integer');
88
+ }
89
+ const sitemap = buildStorystickSitemap(routes, options);
90
+ if (sitemap.length === 0)
91
+ return [[]];
92
+ return Array.from({ length: Math.ceil(sitemap.length / shardSize) }, (_, index) => sitemap.slice(index * shardSize, (index + 1) * shardSize));
93
+ }
@@ -0,0 +1,13 @@
1
+ /** Shared HTTP boundary for framework delivery adapters. No cache or queue policy. */
2
+ export declare function signWebhookBody(body: string, secret: string): Promise<string>;
3
+ export declare function readSignedWebhook(request: Request, options: {
4
+ secret: string;
5
+ signatureHeader?: string;
6
+ }): Promise<{
7
+ ok: true;
8
+ payload: unknown;
9
+ } | {
10
+ ok: false;
11
+ status: number;
12
+ error: string;
13
+ }>;