@sequenceholdings/artifact-studio 0.4.0 → 0.5.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 (90) hide show
  1. package/README.md +35 -1
  2. package/VERSION-PIN.md +9 -0
  3. package/dist/active-deploy-policy.d.ts +9 -3
  4. package/dist/active-deploy-policy.js +20 -8
  5. package/dist/artifact-env-bindings.d.ts +9 -0
  6. package/dist/artifact-env-bindings.js +32 -0
  7. package/dist/build.js +6 -0
  8. package/dist/cli.js +61 -12
  9. package/dist/deployment-validation.d.ts +2 -0
  10. package/dist/deployment-validation.js +2 -0
  11. package/dist/dev-link.js +28 -2
  12. package/dist/env-binding-scan.d.ts +10 -0
  13. package/dist/env-binding-scan.js +179 -0
  14. package/dist/env-bindings.d.ts +52 -0
  15. package/dist/env-bindings.js +180 -0
  16. package/dist/gateway/control.d.ts +33 -0
  17. package/dist/gateway/control.js +29 -0
  18. package/dist/gateway/hosted-proxy.d.ts +5 -0
  19. package/dist/gateway/hosted-proxy.js +4 -1
  20. package/dist/gateway/lattice-invoke.d.ts +48 -0
  21. package/dist/gateway/lattice-invoke.js +245 -0
  22. package/dist/gateway/lattice-upstream.d.ts +38 -0
  23. package/dist/gateway/lattice-upstream.js +136 -0
  24. package/dist/gateway/server.js +2 -3
  25. package/dist/gateway/session.d.ts +3 -1
  26. package/dist/gateway/session.js +10 -3
  27. package/dist/manifest-next.d.ts +55 -0
  28. package/dist/manifest-next.js +35 -2
  29. package/dist/manifest-static.d.ts +35 -0
  30. package/dist/manifest-static.js +12 -0
  31. package/dist/manifest.d.ts +48 -0
  32. package/dist/next-build.d.ts +2 -1
  33. package/dist/next-build.js +12 -7
  34. package/dist/offline/cache.d.ts +4 -0
  35. package/dist/offline/cache.js +7 -0
  36. package/dist/offline/connector.d.ts +26 -0
  37. package/dist/offline/connector.js +118 -0
  38. package/dist/offline/crypto.d.ts +5 -0
  39. package/dist/offline/crypto.js +24 -0
  40. package/dist/offline/index.d.ts +8 -0
  41. package/dist/offline/index.js +7 -0
  42. package/dist/offline/indexed-db.d.ts +10 -0
  43. package/dist/offline/indexed-db.js +104 -0
  44. package/dist/offline/outbox.d.ts +7 -0
  45. package/dist/offline/outbox.js +46 -0
  46. package/dist/offline/replay.d.ts +25 -0
  47. package/dist/offline/replay.js +192 -0
  48. package/dist/offline/secure-store.d.ts +6 -0
  49. package/dist/offline/secure-store.js +51 -0
  50. package/dist/offline/types.d.ts +162 -0
  51. package/dist/offline/types.js +1 -0
  52. package/dist/orm-json-scalars.d.ts +21 -0
  53. package/dist/orm-json-scalars.js +21 -0
  54. package/dist/runtime-contracts.d.ts +7 -7
  55. package/dist/sdk-orm.d.ts +7 -1
  56. package/dist/sdk-orm.js +12 -5
  57. package/dist/sdk.d.ts +126 -0
  58. package/dist/server/broker-contract.d.ts +191 -5
  59. package/dist/server/broker-contract.js +142 -1
  60. package/dist/server/client.d.ts +60 -12
  61. package/dist/server/client.js +143 -20
  62. package/dist/server/errors.d.ts +26 -0
  63. package/dist/server/errors.js +29 -0
  64. package/dist/server/managed-server-path.d.ts +6 -0
  65. package/dist/server/managed-server-path.js +14 -0
  66. package/dist/server/processes.d.ts +290 -0
  67. package/dist/server/processes.js +56 -0
  68. package/dist/server/request-context.d.ts +6 -0
  69. package/dist/server/request-context.js +10 -1
  70. package/dist/server.d.ts +5 -1
  71. package/dist/server.js +1 -0
  72. package/dist/source-resolver.d.ts +9 -2
  73. package/dist/source-resolver.js +55 -8
  74. package/dist/templates/nextjs/AGENTS.md +10 -0
  75. package/dist/templates/nextjs/CLAUDE.md +1 -0
  76. package/dist/templates/nextjs/README.md +88 -0
  77. package/dist/templates/react-vite/AGENTS.md +8 -88
  78. package/dist/templates/react-vite/CLAUDE.md +0 -6
  79. package/dist/templates/react-vite/package.json +1 -1
  80. package/dist/templates/react-vite/src/App.tsx +37 -14
  81. package/dist/templates/react-vite/src/styles.css +10 -0
  82. package/package.json +7 -3
  83. package/templates/nextjs/AGENTS.md +10 -0
  84. package/templates/nextjs/CLAUDE.md +1 -0
  85. package/templates/nextjs/README.md +88 -0
  86. package/templates/react-vite/AGENTS.md +8 -88
  87. package/templates/react-vite/CLAUDE.md +0 -6
  88. package/templates/react-vite/package.json +1 -1
  89. package/templates/react-vite/src/App.tsx +37 -14
  90. package/templates/react-vite/src/styles.css +10 -0
@@ -58,12 +58,16 @@ export async function resolveNextRepositoryId({ spec, repository, baseUrl, token
58
58
  return match[1];
59
59
  }
60
60
  catch { /* Opaque labels/SSH origins are valid preview provenance but cannot attest a release. */ }
61
- throw new Error('Official Next.js releases require a Git Service repository in this Atlas environment. Push the reviewed revision, then run artifact deploy --repo <namespace>/<name> --ref main --env <env>.');
61
+ throw new Error('Official Next.js releases require a Git Service repository in this Atlas environment. Push the reviewed revision, then run artifact deploy --repo <namespace>/<name> --ref main --env <env> (or --ref staging for a staging environment).');
62
62
  }
63
- /** A verified repository build names a commit; anything else uploads the committed source snapshot. */
64
- function nextBuildRequestBody({ manifest, files, commit, provenance, repository, repositoryId, channel }) {
63
+ /**
64
+ * A verified repository build names a commit and the application's directory in it;
65
+ * anything else uploads the committed source snapshot. The repository root is omitted
66
+ * so the request stays valid for Atlas versions that predate `sourceRoot`.
67
+ */
68
+ function nextBuildRequestBody({ manifest, files, commit, provenance, repository, repositoryId, sourceRoot, channel }) {
65
69
  if (repositoryId)
66
- return { repositoryId, commit, idempotencyKey: randomUUID(), channel };
70
+ return { repositoryId, commit, ...(sourceRoot === '.' ? {} : { sourceRoot }), idempotencyKey: randomUUID(), channel };
67
71
  return {
68
72
  manifest,
69
73
  files: files.map(({ path, content, encoding }) => ({ path, content, encoding: encoding ?? 'utf8' })),
@@ -71,7 +75,7 @@ function nextBuildRequestBody({ manifest, files, commit, provenance, repository,
71
75
  idempotencyKey: randomUUID(), channel,
72
76
  };
73
77
  }
74
- export async function submitNextBuild({ root, manifest, projectId, baseUrl, getToken, provenance, repository, repositoryId, channel, out }) {
78
+ export async function submitNextBuild({ root, sourceRoot, manifest, projectId, baseUrl, getToken, provenance, repository, repositoryId, channel, out }) {
75
79
  if (!provenance.gitCommit || !/^[a-f0-9]{40}$/i.test(provenance.gitCommit)) {
76
80
  throw new Error('Commit the application source before requesting a hosted build. Use artifact dev for uncommitted local development.');
77
81
  }
@@ -80,9 +84,10 @@ export async function submitNextBuild({ root, manifest, projectId, baseUrl, getT
80
84
  if (channel === 'active' && !repositoryId)
81
85
  throw new Error('An official release requires a verified Git Service repository.');
82
86
  const path = `/api/artifact-studio/projects/${encodeURIComponent(projectId)}/next`;
83
- const body = nextBuildRequestBody({ manifest, files: source.files, commit: provenance.gitCommit, provenance, repository, repositoryId, channel });
87
+ const body = nextBuildRequestBody({ manifest, files: source.files, commit: provenance.gitCommit, provenance, repository, repositoryId, sourceRoot, channel });
84
88
  const response = operationSchema.parse(await postJson({ baseUrl, token: await getToken(), path: `${path}/builds${repositoryId ? '/from-repository' : ''}`, body }));
85
- console.log(`[seq-studio] Atlas build ${response.operation.id} (${channel}) from ${provenance.gitCommit.slice(0, 12)}`);
89
+ const directory = repositoryId && sourceRoot !== '.' ? ` (${sourceRoot})` : '';
90
+ console.log(`[seq-studio] Atlas build ${response.operation.id} (${channel}) from ${provenance.gitCommit.slice(0, 12)}${directory}`);
86
91
  console.log(`${baseUrl}/artifact-studio/projects/${projectId}`);
87
92
  const controller = new AbortController();
88
93
  const onSignal = () => controller.abort();
@@ -0,0 +1,4 @@
1
+ import type { EncryptedOfflineStore, OfflineCache } from './types.js';
2
+ export declare function createOfflineCache({ store, }: {
3
+ store: EncryptedOfflineStore;
4
+ }): OfflineCache;
@@ -0,0 +1,7 @@
1
+ export function createOfflineCache({ store, }) {
2
+ return {
3
+ read: ({ key }) => store.get({ store: 'cache', key }),
4
+ write: ({ key, entry }) => store.set({ store: 'cache', key, value: entry }),
5
+ remove: ({ key }) => store.delete({ store: 'cache', key }),
6
+ };
7
+ }
@@ -0,0 +1,26 @@
1
+ import type { SequenceOfflineRequest, SequenceOfflineRequestConnector } from '../sdk.js';
2
+ import type { OfflineReplayEngine } from './replay.js';
3
+ import type { OfflineCache, OfflineMutationPolicy, OfflineReadPolicy } from './types.js';
4
+ export declare class OfflineDataUnavailableError extends Error {
5
+ constructor(path: string);
6
+ }
7
+ export declare class OfflineWriteBlockedError extends Error {
8
+ constructor(path: string);
9
+ }
10
+ export interface ArtifactOfflineConnector {
11
+ connector: SequenceOfflineRequestConnector;
12
+ flush(): Promise<void>;
13
+ }
14
+ export declare function createArtifactOfflineConnector({ cache, readPolicy, mutationPolicy, outbox, replay, isOnline, clock, onCacheWriteError, }: {
15
+ cache: OfflineCache;
16
+ readPolicy: OfflineReadPolicy;
17
+ mutationPolicy: OfflineMutationPolicy;
18
+ outbox: import('./types.js').OfflineOutbox;
19
+ replay: OfflineReplayEngine;
20
+ isOnline: () => boolean;
21
+ clock?: () => number;
22
+ onCacheWriteError?: (input: {
23
+ request: SequenceOfflineRequest;
24
+ error: unknown;
25
+ }) => void;
26
+ }): ArtifactOfflineConnector;
@@ -0,0 +1,118 @@
1
+ export class OfflineDataUnavailableError extends Error {
2
+ constructor(path) {
3
+ super(`No current offline data is available for ${path}`);
4
+ this.name = 'OfflineDataUnavailableError';
5
+ }
6
+ }
7
+ export class OfflineWriteBlockedError extends Error {
8
+ constructor(path) {
9
+ super(`Reconnect to complete this action: ${path}`);
10
+ this.name = 'OfflineWriteBlockedError';
11
+ }
12
+ }
13
+ function canUseCachedFallback(response) {
14
+ return response.status === 408 || response.status === 429 || response.status >= 500;
15
+ }
16
+ export function createArtifactOfflineConnector({ cache, readPolicy, mutationPolicy, outbox, replay, isOnline, clock = () => Date.now(), onCacheWriteError, }) {
17
+ async function readCached({ request, key, }) {
18
+ const cached = await cache.read({ key });
19
+ if (!cached || cached.expiresAt < clock()) {
20
+ throw new OfflineDataUnavailableError(request.path);
21
+ }
22
+ // Cache entries are written from this same typed request. The durable
23
+ // store cannot retain the erased generic, so restore it at the boundary.
24
+ return cached.response;
25
+ }
26
+ async function queueMutation({ request, descriptor, requestId, }) {
27
+ const observed = await descriptor.capture?.({ request });
28
+ const entry = await outbox.enqueue({
29
+ ...(requestId === undefined ? {} : { id: requestId }),
30
+ request,
31
+ entity: descriptor.entity,
32
+ ...(observed === undefined ? {} : { observed }),
33
+ });
34
+ await descriptor.optimisticApply?.({ entry });
35
+ const optimistic = descriptor.optimisticResponse?.({ entry }) ?? {
36
+ ok: true,
37
+ status: 202,
38
+ body: { offlineQueued: true, requestId: entry.id },
39
+ };
40
+ // Mutation descriptors define the response shape expected by their own
41
+ // call site; the shared queue deliberately treats payloads as unknown.
42
+ return optimistic;
43
+ }
44
+ const connector = {
45
+ async execute({ request, network }) {
46
+ if (request.method === 'GET') {
47
+ const descriptor = readPolicy.match(request);
48
+ if (!descriptor) {
49
+ if (!isOnline())
50
+ throw new OfflineDataUnavailableError(request.path);
51
+ return network(request);
52
+ }
53
+ if (!isOnline()) {
54
+ return readCached({ request, key: descriptor.key });
55
+ }
56
+ try {
57
+ const response = await network(request);
58
+ if (response.ok) {
59
+ const cachedAt = clock();
60
+ try {
61
+ await cache.write({
62
+ key: descriptor.key,
63
+ entry: {
64
+ response,
65
+ cachedAt,
66
+ expiresAt: cachedAt + descriptor.ttlMs,
67
+ entity: descriptor.entity,
68
+ },
69
+ });
70
+ }
71
+ catch (error) {
72
+ if (onCacheWriteError) {
73
+ onCacheWriteError({ request, error });
74
+ }
75
+ else {
76
+ console.warn('[artifact-studio] offline cache write failed:', error);
77
+ }
78
+ }
79
+ return response;
80
+ }
81
+ return canUseCachedFallback(response)
82
+ ? readCached({ request, key: descriptor.key })
83
+ : response;
84
+ }
85
+ catch {
86
+ return readCached({ request, key: descriptor.key });
87
+ }
88
+ }
89
+ let replayableRequest = request;
90
+ let requestId;
91
+ if (isOnline()) {
92
+ requestId = request.headers?.['X-Request-Id'] ?? globalThis.crypto.randomUUID();
93
+ replayableRequest = {
94
+ ...request,
95
+ headers: { ...request.headers, 'X-Request-Id': requestId },
96
+ };
97
+ try {
98
+ return await network(replayableRequest);
99
+ }
100
+ catch {
101
+ // A transport failure is an offline transition. Only the explicit
102
+ // mutation policy may decide whether this write is durable.
103
+ }
104
+ }
105
+ const descriptor = mutationPolicy.match(request);
106
+ if (!descriptor)
107
+ throw new OfflineWriteBlockedError(request.path);
108
+ return queueMutation({ request: replayableRequest, descriptor, requestId });
109
+ },
110
+ attach: ({ network }) => {
111
+ replay.attach({ network });
112
+ },
113
+ };
114
+ return {
115
+ connector,
116
+ flush: () => replay.flush(),
117
+ };
118
+ }
@@ -0,0 +1,5 @@
1
+ import type { OfflineCrypto } from './types.js';
2
+ export declare function generateOfflineEncryptionKey(): Promise<CryptoKey>;
3
+ export declare function createOfflineCrypto({ key }: {
4
+ key: CryptoKey;
5
+ }): OfflineCrypto;
@@ -0,0 +1,24 @@
1
+ const AES_GCM_IV_BYTES = 12;
2
+ export async function generateOfflineEncryptionKey() {
3
+ return globalThis.crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
4
+ }
5
+ export function createOfflineCrypto({ key }) {
6
+ return {
7
+ async encrypt({ value }) {
8
+ const serialized = JSON.stringify(value);
9
+ if (serialized === undefined) {
10
+ throw new Error('Offline values must be JSON-serializable');
11
+ }
12
+ const iv = globalThis.crypto.getRandomValues(new Uint8Array(AES_GCM_IV_BYTES));
13
+ const ciphertext = await globalThis.crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, new TextEncoder().encode(serialized));
14
+ return {
15
+ iv: Array.from(iv),
16
+ ciphertext: Array.from(new Uint8Array(ciphertext)),
17
+ };
18
+ },
19
+ async decrypt({ record }) {
20
+ const plaintext = await globalThis.crypto.subtle.decrypt({ name: 'AES-GCM', iv: new Uint8Array(record.iv) }, key, new Uint8Array(record.ciphertext));
21
+ return JSON.parse(new TextDecoder().decode(plaintext));
22
+ },
23
+ };
24
+ }
@@ -0,0 +1,8 @@
1
+ export { createArtifactOfflineConnector, OfflineDataUnavailableError, OfflineWriteBlockedError, type ArtifactOfflineConnector, } from './connector.js';
2
+ export { createOfflineCrypto, generateOfflineEncryptionKey, } from './crypto.js';
3
+ export { openOfflineIndexedDatabase, type OfflineIndexedDatabase, } from './indexed-db.js';
4
+ export { createOfflineCache } from './cache.js';
5
+ export { createOfflineOutbox } from './outbox.js';
6
+ export { classifyOfflineReplayFailure, createOfflineReplayEngine, type OfflineReplayEngine, } from './replay.js';
7
+ export { createEncryptedOfflineStore } from './secure-store.js';
8
+ export type { EncryptedOfflineStore, OfflineCache, OfflineCacheEntry, OfflineConflictField, OfflineConflictInfo, OfflineCrypto, OfflineEncryptedRecord, OfflineFailureKind, OfflineKvStore, OfflineMutationDescriptor, OfflineMutationPolicy, OfflineOutbox, OfflineOutboxEntry, OfflineReadDescriptor, OfflineReadPolicy, OfflineStoreName, OfflineUnresolvedWrite, } from './types.js';
@@ -0,0 +1,7 @@
1
+ export { createArtifactOfflineConnector, OfflineDataUnavailableError, OfflineWriteBlockedError, } from './connector.js';
2
+ export { createOfflineCrypto, generateOfflineEncryptionKey, } from './crypto.js';
3
+ export { openOfflineIndexedDatabase, } from './indexed-db.js';
4
+ export { createOfflineCache } from './cache.js';
5
+ export { createOfflineOutbox } from './outbox.js';
6
+ export { classifyOfflineReplayFailure, createOfflineReplayEngine, } from './replay.js';
7
+ export { createEncryptedOfflineStore } from './secure-store.js';
@@ -0,0 +1,10 @@
1
+ import type { OfflineKvStore } from './types.js';
2
+ export interface OfflineIndexedDatabase {
3
+ kv: OfflineKvStore;
4
+ nextSequence(): Promise<number>;
5
+ getOrCreateEncryptionKey(): Promise<CryptoKey>;
6
+ close(): void;
7
+ }
8
+ export declare function openOfflineIndexedDatabase({ databaseName, }: {
9
+ databaseName: string;
10
+ }): Promise<OfflineIndexedDatabase>;
@@ -0,0 +1,104 @@
1
+ import { generateOfflineEncryptionKey } from './crypto.js';
2
+ const OFFLINE_DATABASE_VERSION = 1;
3
+ const META_STORE = 'meta';
4
+ const SECURE_STORES = [
5
+ 'cache',
6
+ 'outbox',
7
+ 'accesslog',
8
+ ];
9
+ function requestResult(request) {
10
+ return new Promise((resolve, reject) => {
11
+ request.onsuccess = () => resolve(request.result);
12
+ request.onerror = () => reject(request.error ?? new Error('IndexedDB request failed'));
13
+ });
14
+ }
15
+ function transactionComplete(transaction) {
16
+ return new Promise((resolve, reject) => {
17
+ transaction.oncomplete = () => resolve();
18
+ transaction.onerror = () => reject(transaction.error ?? new Error('IndexedDB transaction failed'));
19
+ transaction.onabort = () => reject(transaction.error ?? new Error('IndexedDB transaction aborted'));
20
+ });
21
+ }
22
+ export async function openOfflineIndexedDatabase({ databaseName, }) {
23
+ if (!globalThis.indexedDB)
24
+ throw new Error('IndexedDB is unavailable');
25
+ const openRequest = globalThis.indexedDB.open(databaseName, OFFLINE_DATABASE_VERSION);
26
+ openRequest.onupgradeneeded = () => {
27
+ const database = openRequest.result;
28
+ for (const store of SECURE_STORES) {
29
+ if (!database.objectStoreNames.contains(store)) {
30
+ database.createObjectStore(store);
31
+ }
32
+ }
33
+ if (!database.objectStoreNames.contains(META_STORE)) {
34
+ database.createObjectStore(META_STORE);
35
+ }
36
+ };
37
+ const database = await requestResult(openRequest);
38
+ let invalidated = false;
39
+ database.onversionchange = () => {
40
+ invalidated = true;
41
+ database.close();
42
+ };
43
+ function assertOpen() {
44
+ if (invalidated) {
45
+ throw new Error('Offline storage was replaced; reopen the artifact before continuing');
46
+ }
47
+ }
48
+ const kv = {
49
+ async get({ store, key }) {
50
+ assertOpen();
51
+ const transaction = database.transaction(store, 'readonly');
52
+ return requestResult(transaction.objectStore(store).get(key));
53
+ },
54
+ async set({ store, key, value }) {
55
+ assertOpen();
56
+ const transaction = database.transaction(store, 'readwrite');
57
+ transaction.objectStore(store).put(value, key);
58
+ await transactionComplete(transaction);
59
+ },
60
+ async delete({ store, key }) {
61
+ assertOpen();
62
+ const transaction = database.transaction(store, 'readwrite');
63
+ transaction.objectStore(store).delete(key);
64
+ await transactionComplete(transaction);
65
+ },
66
+ async keys({ store }) {
67
+ assertOpen();
68
+ const transaction = database.transaction(store, 'readonly');
69
+ const keys = await requestResult(transaction.objectStore(store).getAllKeys());
70
+ return keys.map(String);
71
+ },
72
+ };
73
+ return {
74
+ kv,
75
+ async nextSequence() {
76
+ assertOpen();
77
+ const transaction = database.transaction(META_STORE, 'readwrite');
78
+ const store = transaction.objectStore(META_STORE);
79
+ const current = await requestResult(store.get('outbox-sequence'));
80
+ const next = typeof current === 'number' ? current + 1 : 1;
81
+ store.put(next, 'outbox-sequence');
82
+ await transactionComplete(transaction);
83
+ return next;
84
+ },
85
+ async getOrCreateEncryptionKey() {
86
+ assertOpen();
87
+ const candidate = await generateOfflineEncryptionKey();
88
+ const transaction = database.transaction(META_STORE, 'readwrite');
89
+ const store = transaction.objectStore(META_STORE);
90
+ const existing = await requestResult(store.get('encryption-key'));
91
+ if (existing instanceof CryptoKey) {
92
+ await transactionComplete(transaction);
93
+ return existing;
94
+ }
95
+ store.put(candidate, 'encryption-key');
96
+ await transactionComplete(transaction);
97
+ return candidate;
98
+ },
99
+ close: () => {
100
+ invalidated = true;
101
+ database.close();
102
+ },
103
+ };
104
+ }
@@ -0,0 +1,7 @@
1
+ import type { EncryptedOfflineStore, OfflineOutbox } from './types.js';
2
+ export declare function createOfflineOutbox({ store, nextSequence, createId, clock, }: {
3
+ store: EncryptedOfflineStore;
4
+ nextSequence: () => Promise<number>;
5
+ createId?: () => string;
6
+ clock?: () => number;
7
+ }): OfflineOutbox;
@@ -0,0 +1,46 @@
1
+ export function createOfflineOutbox({ store, nextSequence, createId = () => globalThis.crypto.randomUUID(), clock = () => Date.now(), }) {
2
+ async function list() {
3
+ const entries = await Promise.all((await store.keys({ store: 'outbox' })).map((key) => store.get({ store: 'outbox', key })));
4
+ return entries
5
+ .filter((entry) => entry !== undefined)
6
+ .sort((left, right) => left.sequence - right.sequence);
7
+ }
8
+ return {
9
+ async enqueue({ id: suppliedId, request, entity, observed, optimisticId }) {
10
+ const id = suppliedId ?? createId();
11
+ const entry = {
12
+ id,
13
+ sequence: await nextSequence(),
14
+ request,
15
+ entity,
16
+ capturedAt: new Date(clock()).toISOString(),
17
+ attempts: 0,
18
+ status: 'pending',
19
+ ...(observed === undefined ? {} : { observed }),
20
+ ...(optimisticId === undefined ? {} : { optimisticId }),
21
+ };
22
+ await store.set({ store: 'outbox', key: id, value: entry });
23
+ return entry;
24
+ },
25
+ list,
26
+ async update({ id, patch }) {
27
+ const existing = await store.get({
28
+ store: 'outbox',
29
+ key: id,
30
+ });
31
+ if (!existing)
32
+ return;
33
+ await store.set({
34
+ store: 'outbox',
35
+ key: id,
36
+ value: {
37
+ ...existing,
38
+ ...patch,
39
+ id: existing.id,
40
+ sequence: existing.sequence,
41
+ },
42
+ });
43
+ },
44
+ remove: ({ id }) => store.delete({ store: 'outbox', key: id }),
45
+ };
46
+ }
@@ -0,0 +1,25 @@
1
+ import type { SequenceOfflineNetwork } from '../sdk.js';
2
+ import type { OfflineFailureKind, OfflineMutationPolicy, OfflineOutbox, OfflineUnresolvedWrite } from './types.js';
3
+ export declare function classifyOfflineReplayFailure(error: unknown): OfflineFailureKind;
4
+ export interface OfflineReplayEngine {
5
+ attach(context: {
6
+ network: SequenceOfflineNetwork;
7
+ }): void;
8
+ flush(): Promise<void>;
9
+ listUnresolved(): Promise<OfflineUnresolvedWrite[]>;
10
+ retry(input: {
11
+ id: string;
12
+ }): Promise<void>;
13
+ keepMine(input: {
14
+ id: string;
15
+ }): Promise<void>;
16
+ discard(input: {
17
+ id: string;
18
+ }): Promise<void>;
19
+ }
20
+ export declare function createOfflineReplayEngine({ outbox, mutationPolicy, maxAttempts, onEpoch, }: {
21
+ outbox: OfflineOutbox;
22
+ mutationPolicy: OfflineMutationPolicy;
23
+ maxAttempts?: number;
24
+ onEpoch?: () => void;
25
+ }): OfflineReplayEngine;
@@ -0,0 +1,192 @@
1
+ class OfflineConflictDetected extends Error {
2
+ info;
3
+ constructor(info) {
4
+ super('Server changed since this offline edit was captured');
5
+ this.name = 'OfflineConflictDetected';
6
+ this.info = info;
7
+ }
8
+ }
9
+ class OfflineReplayHttpError extends Error {
10
+ status;
11
+ constructor(status) {
12
+ super(`Offline replay failed with HTTP ${status}`);
13
+ this.name = 'OfflineReplayHttpError';
14
+ this.status = status;
15
+ }
16
+ }
17
+ function statusFromError(error) {
18
+ if (error &&
19
+ typeof error === 'object' &&
20
+ 'status' in error &&
21
+ typeof error.status === 'number') {
22
+ return error.status;
23
+ }
24
+ return null;
25
+ }
26
+ export function classifyOfflineReplayFailure(error) {
27
+ const status = statusFromError(error);
28
+ if (status === null)
29
+ return 'transient';
30
+ if (status === 401 || status === 403)
31
+ return 'auth';
32
+ if (status === 409 || status === 412)
33
+ return 'conflict';
34
+ if (status === 408 || status === 429 || status >= 500)
35
+ return 'transient';
36
+ return status >= 400 ? 'invalid' : 'transient';
37
+ }
38
+ async function replayEntry({ entry, descriptor, network, }) {
39
+ if (entry.force !== true &&
40
+ descriptor.fetchCurrent &&
41
+ descriptor.detectConflict) {
42
+ const current = await descriptor.fetchCurrent({ entry, network });
43
+ const conflict = descriptor.detectConflict({ entry, current });
44
+ if (conflict)
45
+ throw new OfflineConflictDetected(conflict);
46
+ }
47
+ const response = await network.execute({
48
+ ...entry.request,
49
+ headers: {
50
+ ...entry.request.headers,
51
+ 'X-Request-Id': entry.id,
52
+ },
53
+ });
54
+ if (!response.ok)
55
+ throw new OfflineReplayHttpError(response.status);
56
+ await descriptor.reconcile?.({ entry, response });
57
+ return response;
58
+ }
59
+ export function createOfflineReplayEngine({ outbox, mutationPolicy, maxAttempts = 5, onEpoch, }) {
60
+ let network = null;
61
+ let flushing = false;
62
+ async function flush() {
63
+ if (!network || flushing)
64
+ return;
65
+ flushing = true;
66
+ try {
67
+ for (const entry of await outbox.list()) {
68
+ if (entry.status !== 'pending' && entry.status !== 'inflight')
69
+ continue;
70
+ const descriptor = mutationPolicy.match(entry.request);
71
+ if (!descriptor) {
72
+ await outbox.update({
73
+ id: entry.id,
74
+ patch: {
75
+ status: 'failed',
76
+ failureKind: 'invalid',
77
+ lastError: 'No mutation policy matches this queued write',
78
+ },
79
+ });
80
+ continue;
81
+ }
82
+ await outbox.update({ id: entry.id, patch: { status: 'inflight' } });
83
+ try {
84
+ await replayEntry({ entry, descriptor, network });
85
+ await outbox.remove({ id: entry.id });
86
+ }
87
+ catch (error) {
88
+ if (error instanceof OfflineConflictDetected) {
89
+ await outbox.update({
90
+ id: entry.id,
91
+ patch: {
92
+ status: 'conflict',
93
+ failureKind: 'conflict',
94
+ conflict: error.info,
95
+ lastError: error.message,
96
+ },
97
+ });
98
+ continue;
99
+ }
100
+ const kind = classifyOfflineReplayFailure(error);
101
+ const lastError = error instanceof Error ? error.message : String(error);
102
+ if (kind === 'auth') {
103
+ await outbox.update({
104
+ id: entry.id,
105
+ patch: { status: 'pending', failureKind: kind, lastError },
106
+ });
107
+ break;
108
+ }
109
+ if (kind === 'invalid' || kind === 'conflict') {
110
+ await outbox.update({
111
+ id: entry.id,
112
+ patch: {
113
+ status: kind === 'conflict' ? 'conflict' : 'failed',
114
+ failureKind: kind,
115
+ lastError,
116
+ },
117
+ });
118
+ continue;
119
+ }
120
+ const attempts = entry.attempts + 1;
121
+ await outbox.update({
122
+ id: entry.id,
123
+ patch: {
124
+ attempts,
125
+ status: attempts >= maxAttempts ? 'failed' : 'pending',
126
+ failureKind: 'transient',
127
+ lastError,
128
+ },
129
+ });
130
+ if (attempts < maxAttempts)
131
+ break;
132
+ }
133
+ }
134
+ }
135
+ finally {
136
+ flushing = false;
137
+ onEpoch?.();
138
+ }
139
+ }
140
+ async function rearm({ id, force, }) {
141
+ await outbox.update({
142
+ id,
143
+ patch: {
144
+ status: 'pending',
145
+ attempts: 0,
146
+ force,
147
+ lastError: undefined,
148
+ failureKind: undefined,
149
+ conflict: undefined,
150
+ },
151
+ });
152
+ await flush();
153
+ }
154
+ return {
155
+ attach: ({ network: nextNetwork }) => {
156
+ network = nextNetwork;
157
+ },
158
+ flush,
159
+ async listUnresolved() {
160
+ const writes = [];
161
+ for (const entry of await outbox.list()) {
162
+ const authPaused = entry.status === 'pending' && entry.failureKind === 'auth';
163
+ if (entry.status !== 'failed' &&
164
+ entry.status !== 'conflict' &&
165
+ !authPaused) {
166
+ continue;
167
+ }
168
+ const descriptor = mutationPolicy.match(entry.request);
169
+ writes.push({
170
+ id: entry.id,
171
+ label: descriptor?.label(entry) ?? entry.entity,
172
+ capturedAt: entry.capturedAt,
173
+ kind: entry.failureKind ??
174
+ (entry.status === 'conflict' ? 'conflict' : 'transient'),
175
+ ...(entry.lastError === undefined ? {} : { error: entry.lastError }),
176
+ ...(entry.conflict === undefined ? {} : { conflict: entry.conflict }),
177
+ });
178
+ }
179
+ return writes;
180
+ },
181
+ retry: ({ id }) => rearm({ id }),
182
+ keepMine: ({ id }) => rearm({ id, force: true }),
183
+ async discard({ id }) {
184
+ const entry = (await outbox.list()).find((candidate) => candidate.id === id);
185
+ if (!entry)
186
+ return;
187
+ await mutationPolicy.match(entry.request)?.rollback?.({ entry });
188
+ await outbox.remove({ id });
189
+ onEpoch?.();
190
+ },
191
+ };
192
+ }
@@ -0,0 +1,6 @@
1
+ import type { EncryptedOfflineStore, OfflineCrypto, OfflineKvStore } from './types.js';
2
+ export declare function createEncryptedOfflineStore({ kv, crypto, namespace, }: {
3
+ kv: OfflineKvStore;
4
+ crypto: OfflineCrypto;
5
+ namespace: string;
6
+ }): EncryptedOfflineStore;