@sequenceholdings/artifact-studio 0.4.0 → 0.6.1
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 +35 -1
- package/VERSION-PIN.md +29 -0
- package/dist/active-deploy-policy.d.ts +9 -3
- package/dist/active-deploy-policy.js +20 -8
- package/dist/artifact-env-bindings.d.ts +9 -0
- package/dist/artifact-env-bindings.js +32 -0
- package/dist/build.js +6 -0
- package/dist/cli.js +62 -13
- package/dist/deployment-validation.d.ts +2 -0
- package/dist/deployment-validation.js +2 -0
- package/dist/dev-link.js +28 -2
- package/dist/env-binding-scan.d.ts +10 -0
- package/dist/env-binding-scan.js +179 -0
- package/dist/env-bindings.d.ts +52 -0
- package/dist/env-bindings.js +180 -0
- package/dist/gateway/control.d.ts +33 -0
- package/dist/gateway/control.js +29 -0
- package/dist/gateway/hosted-proxy.d.ts +5 -0
- package/dist/gateway/hosted-proxy.js +4 -1
- package/dist/gateway/lattice-invoke.d.ts +48 -0
- package/dist/gateway/lattice-invoke.js +245 -0
- package/dist/gateway/lattice-upstream.d.ts +38 -0
- package/dist/gateway/lattice-upstream.js +136 -0
- package/dist/gateway/server.js +2 -3
- package/dist/gateway/session.d.ts +3 -1
- package/dist/gateway/session.js +10 -3
- package/dist/manifest-next.d.ts +55 -0
- package/dist/manifest-next.js +35 -2
- package/dist/manifest-static.d.ts +35 -0
- package/dist/manifest-static.js +12 -0
- package/dist/manifest.d.ts +48 -0
- package/dist/next-build.d.ts +2 -1
- package/dist/next-build.js +12 -7
- package/dist/offline/cache.d.ts +4 -0
- package/dist/offline/cache.js +7 -0
- package/dist/offline/connector.d.ts +28 -0
- package/dist/offline/connector.js +122 -0
- package/dist/offline/crypto.d.ts +5 -0
- package/dist/offline/crypto.js +24 -0
- package/dist/offline/index.d.ts +8 -0
- package/dist/offline/index.js +7 -0
- package/dist/offline/indexed-db.d.ts +10 -0
- package/dist/offline/indexed-db.js +104 -0
- package/dist/offline/management-types.d.ts +25 -0
- package/dist/offline/management-types.js +1 -0
- package/dist/offline/outbox.d.ts +7 -0
- package/dist/offline/outbox.js +46 -0
- package/dist/offline/replay.d.ts +25 -0
- package/dist/offline/replay.js +192 -0
- package/dist/offline/secure-store.d.ts +6 -0
- package/dist/offline/secure-store.js +51 -0
- package/dist/offline/types.d.ts +162 -0
- package/dist/offline/types.js +1 -0
- package/dist/orm-json-scalars.d.ts +21 -0
- package/dist/orm-json-scalars.js +21 -0
- package/dist/runtime-contracts.d.ts +7 -7
- package/dist/runtime-contracts.js +1 -1
- package/dist/sdk-orm.d.ts +7 -1
- package/dist/sdk-orm.js +12 -5
- package/dist/sdk.d.ts +134 -1
- package/dist/server/api-client.d.ts +26 -0
- package/dist/server/api-client.js +38 -0
- package/dist/server/broker-contract.d.ts +191 -5
- package/dist/server/broker-contract.js +142 -1
- package/dist/server/client.d.ts +82 -47
- package/dist/server/client.js +138 -59
- package/dist/server/connection-retry.d.ts +12 -0
- package/dist/server/connection-retry.js +40 -0
- package/dist/server/errors.d.ts +61 -0
- package/dist/server/errors.js +89 -0
- package/dist/server/external-context.d.ts +21 -0
- package/dist/server/external-context.js +19 -0
- package/dist/server/internal/signed-token.d.ts +15 -0
- package/dist/server/internal/signed-token.js +67 -0
- package/dist/server/internal/v1-context.d.ts +18 -0
- package/dist/server/internal/v1-context.js +26 -0
- package/dist/server/internal/verify-any.d.ts +22 -0
- package/dist/server/internal/verify-any.js +53 -0
- package/dist/server/logger.d.ts +1 -1
- package/dist/server/managed-server-path.d.ts +6 -0
- package/dist/server/managed-server-path.js +14 -0
- package/dist/server/principal.d.ts +48 -0
- package/dist/server/principal.js +39 -0
- package/dist/server/processes.d.ts +290 -0
- package/dist/server/processes.js +56 -0
- package/dist/server/request-context.d.ts +121 -3
- package/dist/server/request-context.js +111 -33
- package/dist/server/testing.d.ts +40 -0
- package/dist/server/testing.js +86 -0
- package/dist/server.d.ts +13 -4
- package/dist/server.js +6 -2
- package/dist/source-resolver.d.ts +9 -2
- package/dist/source-resolver.js +55 -8
- package/dist/templates/nextjs/AGENTS.md +10 -0
- package/dist/templates/nextjs/CLAUDE.md +1 -0
- package/dist/templates/nextjs/README.md +88 -0
- package/dist/templates/nextjs/package.json +3 -1
- package/dist/templates/nextjs/tests/server.test.mjs +25 -0
- package/dist/templates/react-vite/AGENTS.md +8 -88
- package/dist/templates/react-vite/CLAUDE.md +0 -6
- package/dist/templates/react-vite/package.json +1 -1
- package/dist/templates/react-vite/src/App.tsx +37 -14
- package/dist/templates/react-vite/src/styles.css +10 -0
- package/package.json +15 -3
- package/templates/nextjs/AGENTS.md +10 -0
- package/templates/nextjs/CLAUDE.md +1 -0
- package/templates/nextjs/README.md +88 -0
- package/templates/nextjs/package.json +3 -1
- package/templates/nextjs/tests/server.test.mjs +25 -0
- package/templates/react-vite/AGENTS.md +8 -88
- package/templates/react-vite/CLAUDE.md +0 -6
- package/templates/react-vite/package.json +1 -1
- package/templates/react-vite/src/App.tsx +37 -14
- package/templates/react-vite/src/styles.css +10 -0
|
@@ -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,25 @@
|
|
|
1
|
+
export interface SequenceOfflineManagementStatus {
|
|
2
|
+
supported: boolean;
|
|
3
|
+
enrolled: boolean;
|
|
4
|
+
online: boolean;
|
|
5
|
+
pending: number;
|
|
6
|
+
failed: number;
|
|
7
|
+
syncing: boolean;
|
|
8
|
+
lastSyncAt: number | null;
|
|
9
|
+
itemCount?: number;
|
|
10
|
+
itemLabel?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface SequenceOfflineManagementIssue {
|
|
13
|
+
id: string;
|
|
14
|
+
label: string;
|
|
15
|
+
message: string;
|
|
16
|
+
}
|
|
17
|
+
export interface SequenceOfflineManagement {
|
|
18
|
+
status(): Promise<SequenceOfflineManagementStatus>;
|
|
19
|
+
setEnrolled(enrolled: boolean): Promise<void>;
|
|
20
|
+
sync(): Promise<void>;
|
|
21
|
+
issues(): Promise<SequenceOfflineManagementIssue[]>;
|
|
22
|
+
retry(id: string): Promise<void>;
|
|
23
|
+
discard(id: string): Promise<void>;
|
|
24
|
+
subscribe(listener: () => void): () => void;
|
|
25
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -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,51 @@
|
|
|
1
|
+
function isEncryptedRecord(value) {
|
|
2
|
+
if (!value || typeof value !== 'object')
|
|
3
|
+
return false;
|
|
4
|
+
return ('iv' in value &&
|
|
5
|
+
'ciphertext' in value &&
|
|
6
|
+
Array.isArray(value.iv) &&
|
|
7
|
+
Array.isArray(value.ciphertext));
|
|
8
|
+
}
|
|
9
|
+
export function createEncryptedOfflineStore({ kv, crypto, namespace, }) {
|
|
10
|
+
if (namespace.length === 0 || namespace.includes('\n')) {
|
|
11
|
+
throw new Error('Offline storage namespace is required');
|
|
12
|
+
}
|
|
13
|
+
const prefix = `${namespace}:`;
|
|
14
|
+
const scopedKey = (key) => `${prefix}${key}`;
|
|
15
|
+
async function scopedKeys(store) {
|
|
16
|
+
return (await kv.keys({ store }))
|
|
17
|
+
.filter((key) => key.startsWith(prefix))
|
|
18
|
+
.map((key) => key.slice(prefix.length));
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
async get({ store, key, }) {
|
|
22
|
+
const scoped = scopedKey(key);
|
|
23
|
+
const record = await kv.get({ store, key: scoped });
|
|
24
|
+
if (!isEncryptedRecord(record))
|
|
25
|
+
return undefined;
|
|
26
|
+
try {
|
|
27
|
+
// The caller owns each logical key's schema; the encrypted store is
|
|
28
|
+
// intentionally heterogeneous and cannot infer T after JSON decode.
|
|
29
|
+
return (await crypto.decrypt({ record }));
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
await kv.delete({ store, key: scoped });
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
async set({ store, key, value }) {
|
|
37
|
+
await kv.set({
|
|
38
|
+
store,
|
|
39
|
+
key: scopedKey(key),
|
|
40
|
+
value: await crypto.encrypt({ value }),
|
|
41
|
+
});
|
|
42
|
+
},
|
|
43
|
+
async delete({ store, key }) {
|
|
44
|
+
await kv.delete({ store, key: scopedKey(key) });
|
|
45
|
+
},
|
|
46
|
+
keys: ({ store }) => scopedKeys(store),
|
|
47
|
+
async clear({ store }) {
|
|
48
|
+
await Promise.all((await scopedKeys(store)).map((key) => kv.delete({ store, key: scopedKey(key) })));
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import type { SequenceApiFetchResponse, SequenceOfflineNetwork, SequenceOfflineRequest } from '../sdk.js';
|
|
2
|
+
export type OfflineStoreName = 'cache' | 'outbox' | 'accesslog';
|
|
3
|
+
export interface OfflineKvStore {
|
|
4
|
+
get(input: {
|
|
5
|
+
store: OfflineStoreName;
|
|
6
|
+
key: string;
|
|
7
|
+
}): Promise<unknown>;
|
|
8
|
+
set(input: {
|
|
9
|
+
store: OfflineStoreName;
|
|
10
|
+
key: string;
|
|
11
|
+
value: unknown;
|
|
12
|
+
}): Promise<void>;
|
|
13
|
+
delete(input: {
|
|
14
|
+
store: OfflineStoreName;
|
|
15
|
+
key: string;
|
|
16
|
+
}): Promise<void>;
|
|
17
|
+
keys(input: {
|
|
18
|
+
store: OfflineStoreName;
|
|
19
|
+
}): Promise<string[]>;
|
|
20
|
+
}
|
|
21
|
+
export interface OfflineEncryptedRecord {
|
|
22
|
+
iv: number[];
|
|
23
|
+
ciphertext: number[];
|
|
24
|
+
}
|
|
25
|
+
export interface OfflineCrypto {
|
|
26
|
+
encrypt(input: {
|
|
27
|
+
value: unknown;
|
|
28
|
+
}): Promise<OfflineEncryptedRecord>;
|
|
29
|
+
decrypt(input: {
|
|
30
|
+
record: OfflineEncryptedRecord;
|
|
31
|
+
}): Promise<unknown>;
|
|
32
|
+
}
|
|
33
|
+
export interface EncryptedOfflineStore {
|
|
34
|
+
get<T = unknown>(input: {
|
|
35
|
+
store: OfflineStoreName;
|
|
36
|
+
key: string;
|
|
37
|
+
}): Promise<T | undefined>;
|
|
38
|
+
set(input: {
|
|
39
|
+
store: OfflineStoreName;
|
|
40
|
+
key: string;
|
|
41
|
+
value: unknown;
|
|
42
|
+
}): Promise<void>;
|
|
43
|
+
delete(input: {
|
|
44
|
+
store: OfflineStoreName;
|
|
45
|
+
key: string;
|
|
46
|
+
}): Promise<void>;
|
|
47
|
+
keys(input: {
|
|
48
|
+
store: OfflineStoreName;
|
|
49
|
+
}): Promise<string[]>;
|
|
50
|
+
clear(input: {
|
|
51
|
+
store: OfflineStoreName;
|
|
52
|
+
}): Promise<void>;
|
|
53
|
+
}
|
|
54
|
+
export type OfflineFailureKind = 'transient' | 'invalid' | 'auth' | 'conflict';
|
|
55
|
+
export type OfflineOutboxStatus = 'pending' | 'inflight' | 'failed' | 'conflict';
|
|
56
|
+
export interface OfflineConflictField {
|
|
57
|
+
key: string;
|
|
58
|
+
mine: unknown;
|
|
59
|
+
theirs: unknown;
|
|
60
|
+
}
|
|
61
|
+
export interface OfflineConflictInfo {
|
|
62
|
+
fields: OfflineConflictField[];
|
|
63
|
+
observedAt?: string;
|
|
64
|
+
serverUpdatedAt?: string;
|
|
65
|
+
}
|
|
66
|
+
export interface OfflineOutboxEntry {
|
|
67
|
+
id: string;
|
|
68
|
+
sequence: number;
|
|
69
|
+
request: SequenceOfflineRequest;
|
|
70
|
+
entity: string;
|
|
71
|
+
capturedAt: string;
|
|
72
|
+
attempts: number;
|
|
73
|
+
status: OfflineOutboxStatus;
|
|
74
|
+
observed?: Record<string, unknown>;
|
|
75
|
+
optimisticId?: string;
|
|
76
|
+
failureKind?: OfflineFailureKind;
|
|
77
|
+
lastError?: string;
|
|
78
|
+
conflict?: OfflineConflictInfo;
|
|
79
|
+
force?: boolean;
|
|
80
|
+
}
|
|
81
|
+
export interface OfflineOutbox {
|
|
82
|
+
enqueue(input: {
|
|
83
|
+
id?: string;
|
|
84
|
+
request: SequenceOfflineRequest;
|
|
85
|
+
entity: string;
|
|
86
|
+
observed?: Record<string, unknown>;
|
|
87
|
+
optimisticId?: string;
|
|
88
|
+
}): Promise<OfflineOutboxEntry>;
|
|
89
|
+
list(): Promise<OfflineOutboxEntry[]>;
|
|
90
|
+
update(input: {
|
|
91
|
+
id: string;
|
|
92
|
+
patch: Partial<OfflineOutboxEntry>;
|
|
93
|
+
}): Promise<void>;
|
|
94
|
+
remove(input: {
|
|
95
|
+
id: string;
|
|
96
|
+
}): Promise<void>;
|
|
97
|
+
}
|
|
98
|
+
export interface OfflineReadDescriptor {
|
|
99
|
+
key: string;
|
|
100
|
+
ttlMs: number;
|
|
101
|
+
entity: string;
|
|
102
|
+
}
|
|
103
|
+
export interface OfflineReadPolicy {
|
|
104
|
+
match(request: SequenceOfflineRequest): OfflineReadDescriptor | null;
|
|
105
|
+
}
|
|
106
|
+
export interface OfflineCacheEntry {
|
|
107
|
+
response: SequenceApiFetchResponse;
|
|
108
|
+
cachedAt: number;
|
|
109
|
+
expiresAt: number;
|
|
110
|
+
entity: string;
|
|
111
|
+
}
|
|
112
|
+
export interface OfflineCache {
|
|
113
|
+
read(input: {
|
|
114
|
+
key: string;
|
|
115
|
+
}): Promise<OfflineCacheEntry | undefined>;
|
|
116
|
+
write(input: {
|
|
117
|
+
key: string;
|
|
118
|
+
entry: OfflineCacheEntry;
|
|
119
|
+
}): Promise<void>;
|
|
120
|
+
remove(input: {
|
|
121
|
+
key: string;
|
|
122
|
+
}): Promise<void>;
|
|
123
|
+
}
|
|
124
|
+
export interface OfflineMutationDescriptor {
|
|
125
|
+
entity: string;
|
|
126
|
+
label(entry: OfflineOutboxEntry): string;
|
|
127
|
+
capture?(input: {
|
|
128
|
+
request: SequenceOfflineRequest;
|
|
129
|
+
}): Promise<Record<string, unknown> | undefined>;
|
|
130
|
+
optimisticApply?(input: {
|
|
131
|
+
entry: OfflineOutboxEntry;
|
|
132
|
+
}): Promise<void> | void;
|
|
133
|
+
optimisticResponse?(input: {
|
|
134
|
+
entry: OfflineOutboxEntry;
|
|
135
|
+
}): SequenceApiFetchResponse;
|
|
136
|
+
fetchCurrent?(input: {
|
|
137
|
+
entry: OfflineOutboxEntry;
|
|
138
|
+
network: SequenceOfflineNetwork;
|
|
139
|
+
}): Promise<unknown>;
|
|
140
|
+
detectConflict?(input: {
|
|
141
|
+
entry: OfflineOutboxEntry;
|
|
142
|
+
current: unknown;
|
|
143
|
+
}): OfflineConflictInfo | null;
|
|
144
|
+
reconcile?(input: {
|
|
145
|
+
entry: OfflineOutboxEntry;
|
|
146
|
+
response: SequenceApiFetchResponse;
|
|
147
|
+
}): Promise<void> | void;
|
|
148
|
+
rollback?(input: {
|
|
149
|
+
entry: OfflineOutboxEntry;
|
|
150
|
+
}): Promise<void> | void;
|
|
151
|
+
}
|
|
152
|
+
export interface OfflineMutationPolicy {
|
|
153
|
+
match(request: SequenceOfflineRequest): OfflineMutationDescriptor | null;
|
|
154
|
+
}
|
|
155
|
+
export interface OfflineUnresolvedWrite {
|
|
156
|
+
id: string;
|
|
157
|
+
label: string;
|
|
158
|
+
capturedAt: string;
|
|
159
|
+
kind: OfflineFailureKind;
|
|
160
|
+
error?: string;
|
|
161
|
+
conflict?: OfflineConflictInfo;
|
|
162
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ORM data plane's opt-in exact-number wire form (orm-data-plane/src/protocol.ts).
|
|
3
|
+
* By default numeric and bigint values come back as JSON numbers, which JavaScript
|
|
4
|
+
* rounds past 15-17 significant digits. With `lossless-v1` they come back as strings
|
|
5
|
+
* ("250000.00", "9007199254740993"), and a successful response acknowledges the form
|
|
6
|
+
* with the same header. Variables may carry numeric and bigint values as decimal
|
|
7
|
+
* strings in either form.
|
|
8
|
+
*/
|
|
9
|
+
export declare const ORM_JSON_SCALARS_HEADER = "x-sequence-orm-json-scalars";
|
|
10
|
+
export declare const ORM_LOSSLESS_JSON_SCALARS = "lossless-v1";
|
|
11
|
+
export type ArtifactOrmJsonScalars = typeof ORM_LOSSLESS_JSON_SCALARS;
|
|
12
|
+
/**
|
|
13
|
+
* An executor that asked for exact numbers refuses a successful result the platform
|
|
14
|
+
* did not acknowledge, because its numbers may already be rounded. Failed responses
|
|
15
|
+
* never acknowledge and keep their own error.
|
|
16
|
+
*/
|
|
17
|
+
export declare function assertJsonScalarsAcknowledged({ requested, acknowledged, status }: {
|
|
18
|
+
requested: ArtifactOrmJsonScalars | undefined;
|
|
19
|
+
acknowledged: string | null | undefined;
|
|
20
|
+
status: number;
|
|
21
|
+
}): void;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { OrmOperationError } from '@sequenceholdings/orm/client';
|
|
2
|
+
/**
|
|
3
|
+
* The ORM data plane's opt-in exact-number wire form (orm-data-plane/src/protocol.ts).
|
|
4
|
+
* By default numeric and bigint values come back as JSON numbers, which JavaScript
|
|
5
|
+
* rounds past 15-17 significant digits. With `lossless-v1` they come back as strings
|
|
6
|
+
* ("250000.00", "9007199254740993"), and a successful response acknowledges the form
|
|
7
|
+
* with the same header. Variables may carry numeric and bigint values as decimal
|
|
8
|
+
* strings in either form.
|
|
9
|
+
*/
|
|
10
|
+
export const ORM_JSON_SCALARS_HEADER = 'x-sequence-orm-json-scalars';
|
|
11
|
+
export const ORM_LOSSLESS_JSON_SCALARS = 'lossless-v1';
|
|
12
|
+
/**
|
|
13
|
+
* An executor that asked for exact numbers refuses a successful result the platform
|
|
14
|
+
* did not acknowledge, because its numbers may already be rounded. Failed responses
|
|
15
|
+
* never acknowledge and keep their own error.
|
|
16
|
+
*/
|
|
17
|
+
export function assertJsonScalarsAcknowledged({ requested, acknowledged, status }) {
|
|
18
|
+
if (requested === undefined || acknowledged === requested)
|
|
19
|
+
return;
|
|
20
|
+
throw new OrmOperationError(`Atlas did not confirm ${requested} numbers for this ORM response, so its numeric and bigint values may be rounded; update Atlas before relying on exact numbers`, status, 'LOSSLESS_TRANSPORT_UNAVAILABLE');
|
|
21
|
+
}
|