yaml-flow 8.6.3 → 8.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/browser/adapters/firebase-storage.js +3 -0
- package/browser/adapters/firestore-storage.js +3 -0
- package/browser/adapters/localstorage-storage.js +4 -0
- package/browser/asset-integrity.json +20 -4
- package/browser/server-runtime-controlface.js +8 -0
- package/examples/ARCHITECTURE.md +5 -32
- package/examples/board/demo-shell-with-server.html +2 -2
- package/examples/board/doc.html +2 -2
- package/examples/board/server/board-server.js +4 -2
- package/examples/board/server/chat-flow/flow-steps.json +10 -5
- package/examples/board/test/server-http-test.js +93 -0
- package/examples/board-firestore/README.md +81 -0
- package/examples/board-firestore/browser/board-runtime.js +263 -0
- package/examples/board-firestore/firestore.indexes.json +29 -0
- package/examples/board-firestore/package.json +14 -0
- package/examples/board-firestore/server/adapters/firestore-archive-factory.js +59 -0
- package/examples/board-firestore/server/adapters/firestore-blob-storage.js +82 -0
- package/examples/board-firestore/server/adapters/firestore-board-adapter.js +127 -0
- package/examples/board-firestore/server/adapters/firestore-journal-storage.js +54 -0
- package/examples/board-firestore/server/adapters/firestore-kv-storage.js +47 -0
- package/examples/board-firestore/server/adapters/firestore-lock.js +62 -0
- package/examples/board-firestore/server/adapters/firestore-queue-storage.js +186 -0
- package/examples/board-firestore/server/adapters/firestore-scratch-storage.js +50 -0
- package/examples/board-firestore/server/worker.js +146 -0
- package/lib/{artifacts-store-lib-BR-Samty.d.cts → artifacts-store-lib-D9nMkVcE.d.cts} +1 -1
- package/lib/{artifacts-store-lib-DT7XlWUL.d.ts → artifacts-store-lib-DSSMqVL2.d.ts} +1 -1
- package/lib/artifacts-store-public.d.cts +2 -2
- package/lib/artifacts-store-public.d.ts +2 -2
- package/lib/board-live-cards-mcp.cjs +1 -1
- package/lib/board-live-cards-mcp.d.cts +51 -3
- package/lib/board-live-cards-mcp.d.ts +51 -3
- package/lib/board-live-cards-mcp.js +1 -1
- package/lib/board-live-cards-node.cjs +8 -8
- package/lib/board-live-cards-node.d.cts +13 -9
- package/lib/board-live-cards-node.d.ts +13 -9
- package/lib/board-live-cards-node.js +8 -8
- package/lib/{board-live-cards-public-BMUIPOrc.d.ts → board-live-cards-public-D-DJek3X.d.ts} +1 -1
- package/lib/{board-live-cards-public-wkNmBIRC.d.cts → board-live-cards-public-DQzPe7A9.d.cts} +1 -1
- package/lib/board-live-cards-public-async-3hUuHxDx.d.ts +55 -0
- package/lib/board-live-cards-public-async-CgMCYYft.d.cts +55 -0
- package/lib/board-live-cards-public.d.cts +1 -1
- package/lib/board-live-cards-public.d.ts +1 -1
- package/lib/board-live-cards-server-runtime.cjs +1 -1
- package/lib/board-live-cards-server-runtime.d.cts +10 -6
- package/lib/board-live-cards-server-runtime.d.ts +10 -6
- package/lib/board-live-cards-server-runtime.js +1 -1
- package/lib/board-platform-adapter-async-DOfEq_HC.d.cts +129 -0
- package/lib/board-platform-adapter-async-JZPCzZnH.d.ts +129 -0
- package/lib/board-worker-adapter.cjs +3 -3
- package/lib/board-worker-adapter.js +3 -3
- package/lib/card-store-public.d.cts +1 -1
- package/lib/card-store-public.d.ts +1 -1
- package/lib/{chat-storage-lib-BIUbE-fM.d.cts → chat-storage-lib-B9Q34Dyv.d.cts} +1 -1
- package/lib/{chat-storage-lib-BlG-sobS.d.ts → chat-storage-lib-DB9iSai2.d.ts} +1 -1
- package/lib/chat-store-public.d.cts +2 -2
- package/lib/chat-store-public.d.ts +2 -2
- package/lib/chunk-272IYUKT.cjs +2 -0
- package/lib/chunk-5XHOHTLZ.cjs +2 -0
- package/lib/chunk-6APH25VI.js +2 -0
- package/lib/chunk-7FGPOGRV.cjs +2 -0
- package/lib/chunk-7ICPAABP.cjs +7 -0
- package/lib/chunk-CPAXTVBQ.cjs +2 -0
- package/lib/chunk-DDYSXP2A.cjs +3 -0
- package/lib/chunk-EGRHWZRV.js +2 -0
- package/lib/chunk-GL2OHR2E.cjs +2 -0
- package/lib/chunk-IPLSRN6P.cjs +4 -0
- package/lib/chunk-J5J6BG7B.js +2 -0
- package/lib/chunk-KAWQPLIE.cjs +2 -0
- package/lib/chunk-LPXVVMQT.cjs +2 -0
- package/lib/chunk-M3OU6IS5.cjs +2 -0
- package/lib/chunk-M6STQR5F.cjs +2 -0
- package/lib/chunk-NJJ7WEDT.cjs +2 -0
- package/lib/chunk-NKIQRCOM.cjs +2 -0
- package/lib/chunk-NM6O35RY.cjs +2 -0
- package/lib/chunk-NTICU4OK.js +2 -0
- package/lib/chunk-O7NOHKVR.js +2 -0
- package/lib/chunk-PRHQBGPT.js +2 -0
- package/lib/chunk-S44QZUDX.js +2 -0
- package/lib/chunk-SGV7PU4H.js +2 -0
- package/lib/chunk-TSN3RTXT.js +4 -0
- package/lib/chunk-VXJHBWK3.js +2 -0
- package/lib/chunk-WHDEBJLT.js +7 -0
- package/lib/chunk-XYN5D3GL.js +2 -0
- package/lib/chunk-YGALANRO.js +2 -0
- package/lib/chunk-ZJ5M5COT.js +2 -0
- package/lib/chunk-ZXQR7GHT.js +3 -0
- package/lib/cloud-storage.cjs +1 -1
- package/lib/cloud-storage.d.cts +5 -3
- package/lib/cloud-storage.d.ts +5 -3
- package/lib/cloud-storage.js +1 -1
- package/lib/firebase-storage/index.cjs +3 -0
- package/lib/firebase-storage/index.d.cts +57 -0
- package/lib/firebase-storage/index.d.ts +57 -0
- package/lib/firebase-storage/index.js +3 -0
- package/lib/firestore-storage/index.cjs +3 -0
- package/lib/firestore-storage/index.d.cts +98 -0
- package/lib/firestore-storage/index.d.ts +98 -0
- package/lib/firestore-storage/index.js +3 -0
- package/lib/localstorage-storage/index.cjs +2 -0
- package/lib/localstorage-storage/index.d.cts +39 -0
- package/lib/localstorage-storage/index.d.ts +39 -0
- package/lib/localstorage-storage/index.js +2 -0
- package/lib/mcp-tool-registries-BBObLYga.d.ts +41 -0
- package/lib/mcp-tool-registries-W3TRj6O5.d.cts +41 -0
- package/lib/queue-lane-registry-PaZuFpwp.d.cts +30 -0
- package/lib/queue-lane-registry-PaZuFpwp.d.ts +30 -0
- package/lib/server-jobs-queue-runner/index.cjs +2 -0
- package/lib/server-jobs-queue-runner/index.d.cts +22 -0
- package/lib/server-jobs-queue-runner/index.d.ts +22 -0
- package/lib/server-jobs-queue-runner/index.js +2 -0
- package/lib/server-runtime/index.cjs +1 -1
- package/lib/server-runtime/index.d.cts +11 -17
- package/lib/server-runtime/index.d.ts +11 -17
- package/lib/server-runtime/index.js +1 -1
- package/lib/server-runtime-agentface/index.cjs +2 -0
- package/lib/server-runtime-agentface/index.d.cts +53 -0
- package/lib/server-runtime-agentface/index.d.ts +53 -0
- package/lib/server-runtime-agentface/index.js +2 -0
- package/lib/server-runtime-controlface/index.cjs +2 -0
- package/lib/server-runtime-controlface/index.d.cts +80 -0
- package/lib/server-runtime-controlface/index.d.ts +80 -0
- package/lib/server-runtime-controlface/index.js +2 -0
- package/lib/server-runtime-core/index.cjs +2 -0
- package/lib/server-runtime-core/index.d.cts +376 -0
- package/lib/server-runtime-core/index.d.ts +376 -0
- package/lib/server-runtime-core/index.js +2 -0
- package/lib/server-runtime-watchers/index.cjs +2 -0
- package/lib/server-runtime-watchers/index.d.cts +127 -0
- package/lib/server-runtime-watchers/index.d.ts +127 -0
- package/lib/server-runtime-watchers/index.js +2 -0
- package/lib/server-runtime-webhooks/index.cjs +2 -0
- package/lib/server-runtime-webhooks/index.d.cts +34 -0
- package/lib/server-runtime-webhooks/index.d.ts +34 -0
- package/lib/server-runtime-webhooks/index.js +2 -0
- package/lib/storage-async-interface-BRR4eBjx.d.cts +81 -0
- package/lib/storage-async-interface-DhlOVPSp.d.ts +81 -0
- package/lib/{queue-lane-registry-BPKWWgd4.d.cts → types-BzQY45dH.d.cts} +8 -34
- package/lib/{queue-lane-registry-Be6c0ftj.d.ts → types-CF2xUcZW.d.ts} +8 -34
- package/package.json +46 -2
- package/examples/board-local/demo-shell-localstorage.html +0 -843
- package/lib/board-live-cards-public-async-DKZqbJVU.d.ts +0 -256
- package/lib/board-live-cards-public-async-dMWNbWq6.d.cts +0 -256
- package/lib/chunk-GJJMEAVN.cjs +0 -2
- package/lib/chunk-GLIX37VG.cjs +0 -8
- package/lib/chunk-LRVAVWAG.js +0 -8
- package/lib/chunk-MLVTJASJ.js +0 -2
- package/lib/chunk-SCWHDI3I.js +0 -2
- package/lib/chunk-WOALA3V5.cjs +0 -2
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* firestore-archive-factory.js
|
|
3
|
+
*
|
|
4
|
+
* AsyncArchiveFactory backed by Firestore.
|
|
5
|
+
* Each named stream is a subcollection: boards/{boardId}/archive-stream-{name}
|
|
6
|
+
* Each named blob collection: boards/{boardId}/archive-blob-{name}
|
|
7
|
+
*
|
|
8
|
+
* NOTE: listStreams / listBlobs use Admin SDK's listCollections() — not available
|
|
9
|
+
* in the browser Firestore JS SDK. In the browser, skip or maintain an index manually.
|
|
10
|
+
*
|
|
11
|
+
* @param {import('@google-cloud/firestore').Firestore} db
|
|
12
|
+
* @param {string} boardId
|
|
13
|
+
* @returns {import('yaml-flow/cloud-storage').AsyncArchiveFactory}
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { createFirestoreJournalStorage } from './firestore-journal-storage.js';
|
|
17
|
+
import { createFirestoreBlobStorage } from './firestore-blob-storage.js';
|
|
18
|
+
|
|
19
|
+
export function createFirestoreArchiveFactory(db, boardId) {
|
|
20
|
+
const boardDoc = db.collection('boards').doc(boardId);
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
stream(name) {
|
|
24
|
+
return createFirestoreJournalStorage(boardDoc.collection(`archive-stream-${name}`));
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
blob(name) {
|
|
28
|
+
return createFirestoreBlobStorage(boardDoc.collection(`archive-blob-${name}`));
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
async listStreams(prefix = '') {
|
|
32
|
+
const cols = await boardDoc.listCollections();
|
|
33
|
+
const tag = `archive-stream-${prefix}`;
|
|
34
|
+
return cols
|
|
35
|
+
.map((c) => c.id)
|
|
36
|
+
.filter((id) => id.startsWith(tag))
|
|
37
|
+
.map((id) => id.slice('archive-stream-'.length));
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
async listBlobs(prefix = '') {
|
|
41
|
+
const cols = await boardDoc.listCollections();
|
|
42
|
+
const tag = `archive-blob-${prefix}`;
|
|
43
|
+
return cols
|
|
44
|
+
.map((c) => c.id)
|
|
45
|
+
.filter((id) => id.startsWith(tag))
|
|
46
|
+
.map((id) => id.slice('archive-blob-'.length));
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
config: {
|
|
50
|
+
async get(k) {
|
|
51
|
+
const snap = await boardDoc.collection('archive-config').doc('main').get();
|
|
52
|
+
return snap.data()?.[k] ?? null;
|
|
53
|
+
},
|
|
54
|
+
async set(k, v) {
|
|
55
|
+
await boardDoc.collection('archive-config').doc('main').set({ [k]: v }, { merge: true });
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* firestore-blob-storage.js
|
|
3
|
+
*
|
|
4
|
+
* AsyncBlobStorage backed by Firestore for text/small blobs.
|
|
5
|
+
* Document shape: { k, content, contentType?, bytesBase64? }
|
|
6
|
+
*
|
|
7
|
+
* Binary blobs (readBytes/writeBytes) are stored as base64-encoded strings.
|
|
8
|
+
* For production use with files > 1 MB, replace writeBytes/readBytes with
|
|
9
|
+
* Firebase Storage (Cloud Storage for Firebase) and store the GCS path here.
|
|
10
|
+
*
|
|
11
|
+
* @param {import('@google-cloud/firestore').CollectionReference} col
|
|
12
|
+
* @returns {import('yaml-flow/cloud-storage').AsyncBlobStorage}
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
function encodeDocId(key) {
|
|
16
|
+
return Buffer.from(key).toString('base64url');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function decodeDocId(docId) {
|
|
20
|
+
return Buffer.from(docId, 'base64url').toString();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function createFirestoreBlobStorage(col) {
|
|
24
|
+
return {
|
|
25
|
+
async read(key) {
|
|
26
|
+
const snap = await col.doc(encodeDocId(key)).get();
|
|
27
|
+
if (!snap.exists) return null;
|
|
28
|
+
return snap.data()?.content ?? null;
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
async write(key, content) {
|
|
32
|
+
await col.doc(encodeDocId(key)).set({ k: key, content });
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
async exists(key) {
|
|
36
|
+
const snap = await col.doc(encodeDocId(key)).get();
|
|
37
|
+
return snap.exists;
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
async remove(key) {
|
|
41
|
+
await col.doc(encodeDocId(key)).delete();
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
async readBytes(key) {
|
|
45
|
+
const snap = await col.doc(encodeDocId(key)).get();
|
|
46
|
+
if (!snap.exists) return null;
|
|
47
|
+
const data = snap.data();
|
|
48
|
+
if (data?.bytesBase64) return Buffer.from(data.bytesBase64, 'base64');
|
|
49
|
+
if (data?.content) return Buffer.from(data.content);
|
|
50
|
+
return null;
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
async writeBytes(key, bytes) {
|
|
54
|
+
// NOTE: Firestore 1 MB doc limit. For larger files use Firebase Storage.
|
|
55
|
+
const bytesBase64 = Buffer.from(bytes).toString('base64');
|
|
56
|
+
await col.doc(encodeDocId(key)).set({ k: key, bytesBase64 });
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
async listKeys(prefix = '') {
|
|
60
|
+
let q = col;
|
|
61
|
+
if (prefix) {
|
|
62
|
+
q = col.where('k', '>=', prefix).where('k', '<', prefix + '\uf8ff');
|
|
63
|
+
}
|
|
64
|
+
const snap = await q.orderBy('k').get();
|
|
65
|
+
return snap.docs.map((d) => d.data().k ?? decodeDocId(d.id));
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
async stat(key) {
|
|
69
|
+
const snap = await col.doc(encodeDocId(key)).get();
|
|
70
|
+
if (!snap.exists) return null;
|
|
71
|
+
const data = snap.data();
|
|
72
|
+
const size = data?.bytesBase64
|
|
73
|
+
? Math.floor((data.bytesBase64.length * 3) / 4)
|
|
74
|
+
: (data?.content?.length ?? 0);
|
|
75
|
+
return { key, size, contentType: data?.contentType ?? 'application/octet-stream' };
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
keyRef(key) {
|
|
79
|
+
return { kind: 'firestore-blob', value: `${col.path}/${encodeDocId(key)}` };
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* firestore-board-adapter.js
|
|
3
|
+
*
|
|
4
|
+
* Composes all Firestore-backed storage primitives into an AsyncBoardPlatformAdapter
|
|
5
|
+
* using createHostedAsyncBoardPlatformAdapter from yaml-flow/cloud-storage.
|
|
6
|
+
*
|
|
7
|
+
* Firestore collection layout under boards/{boardId}/:
|
|
8
|
+
* kv-{namespace}/ AsyncKVStorage per namespace
|
|
9
|
+
* journal/ single board journal
|
|
10
|
+
* worker-queue/ board worker task queue
|
|
11
|
+
* chat-queue/ chat agent dispatch queue
|
|
12
|
+
* process-queue/ processAccumulated trigger queue
|
|
13
|
+
* blobs-{namespace}/ AsyncBlobStorage per namespace
|
|
14
|
+
* scratch/ ephemeral scratch storage
|
|
15
|
+
* archive-stream-{name}/ archive journal streams
|
|
16
|
+
* archive-blob-{name}/ archive blob collections
|
|
17
|
+
* archive-config/ archive config KV
|
|
18
|
+
* locks/board-lock AtomicRelayLock document
|
|
19
|
+
*
|
|
20
|
+
* @param {import('@google-cloud/firestore').Firestore} db
|
|
21
|
+
* @param {string} boardId
|
|
22
|
+
* @param {{ holderId?: string, requestProcessAccumulated?: () => void }} [options]
|
|
23
|
+
* @returns {import('yaml-flow/cloud-storage').AsyncBoardPlatformAdapter}
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
27
|
+
import { createHostedAsyncBoardPlatformAdapter } from 'yaml-flow/cloud-storage';
|
|
28
|
+
|
|
29
|
+
import { createFirestoreKvStorage } from './firestore-kv-storage.js';
|
|
30
|
+
import { createFirestoreJournalStorage } from './firestore-journal-storage.js';
|
|
31
|
+
import { createFirestoreQueueStorage } from './firestore-queue-storage.js';
|
|
32
|
+
import { createFirestoreBlobStorage } from './firestore-blob-storage.js';
|
|
33
|
+
import { createFirestoreScratchStorage } from './firestore-scratch-storage.js';
|
|
34
|
+
import { createFirestoreArchiveFactory } from './firestore-archive-factory.js';
|
|
35
|
+
import { createFirestoreLock } from './firestore-lock.js';
|
|
36
|
+
|
|
37
|
+
function parseKindValueRef(ref) {
|
|
38
|
+
const PREFIX = 'b64:';
|
|
39
|
+
if (!ref.startsWith(PREFIX)) return { kind: 'unknown', value: ref };
|
|
40
|
+
const b64 = ref.slice(PREFIX.length)
|
|
41
|
+
.replace(/-/g, '+').replace(/_/g, '/')
|
|
42
|
+
+ '='.repeat((4 - (ref.length - PREFIX.length) % 4) % 4);
|
|
43
|
+
return JSON.parse(Buffer.from(b64, 'base64').toString());
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function lexicalId() {
|
|
47
|
+
const ts = String(Date.now()).padStart(13, '0');
|
|
48
|
+
const rand = Math.random().toString(36).slice(2, 10).padEnd(8, '0');
|
|
49
|
+
return `${ts}-${rand}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function createFirestoreBoardAdapter(db, boardId, options = {}) {
|
|
53
|
+
const boardDoc = db.collection('boards').doc(boardId);
|
|
54
|
+
|
|
55
|
+
return createHostedAsyncBoardPlatformAdapter({
|
|
56
|
+
boardId,
|
|
57
|
+
|
|
58
|
+
// ── KV storage ────────────────────────────────────────────────────────
|
|
59
|
+
kvStorage(namespace) {
|
|
60
|
+
return createFirestoreKvStorage(boardDoc.collection(`kv-${namespace || 'root'}`));
|
|
61
|
+
},
|
|
62
|
+
kvStorageForRef(ref) {
|
|
63
|
+
const parsed = parseKindValueRef(ref);
|
|
64
|
+
// ref.value is treated as a full Firestore collection path when kind='firestore'
|
|
65
|
+
const col = parsed.kind === 'firestore'
|
|
66
|
+
? db.collection(parsed.value)
|
|
67
|
+
: boardDoc.collection(`kv-ref-${Buffer.from(ref).toString('base64url').slice(0, 16)}`);
|
|
68
|
+
return createFirestoreKvStorage(col);
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
// ── Blob storage ──────────────────────────────────────────────────────
|
|
72
|
+
blobStorage(namespace) {
|
|
73
|
+
return createFirestoreBlobStorage(boardDoc.collection(`blobs-${namespace || 'root'}`));
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
// ── Scratch storage ───────────────────────────────────────────────────
|
|
77
|
+
scratchStorage() {
|
|
78
|
+
return createFirestoreScratchStorage(boardDoc.collection('scratch'));
|
|
79
|
+
},
|
|
80
|
+
scratchStorageForRef(ref) {
|
|
81
|
+
const parsed = parseKindValueRef(ref);
|
|
82
|
+
const col = parsed.kind === 'firestore'
|
|
83
|
+
? db.collection(parsed.value)
|
|
84
|
+
: boardDoc.collection('scratch');
|
|
85
|
+
return createFirestoreScratchStorage(col);
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
// ── Archive factory ───────────────────────────────────────────────────
|
|
89
|
+
archiveFactory() {
|
|
90
|
+
return createFirestoreArchiveFactory(db, boardId);
|
|
91
|
+
},
|
|
92
|
+
archiveFactoryForRef(ref) {
|
|
93
|
+
const parsed = parseKindValueRef(ref);
|
|
94
|
+
const altBoardId = parsed.kind === 'firestore' ? parsed.value : `${boardId}-archive`;
|
|
95
|
+
return createFirestoreArchiveFactory(db, altBoardId);
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
// ── Journal storage ───────────────────────────────────────────────────
|
|
99
|
+
journalStorage() {
|
|
100
|
+
return createFirestoreJournalStorage(boardDoc.collection('journal'));
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
// ── Queue storage ─────────────────────────────────────────────────────
|
|
104
|
+
queueStorage: createFirestoreQueueStorage(boardDoc.collection('worker-queue')),
|
|
105
|
+
chatAgentQueueStorage: createFirestoreQueueStorage(boardDoc.collection('chat-queue')),
|
|
106
|
+
processAccumulatedQueueStorage: createFirestoreQueueStorage(boardDoc.collection('process-queue')),
|
|
107
|
+
|
|
108
|
+
// ── Lock ──────────────────────────────────────────────────────────────
|
|
109
|
+
lock: createFirestoreLock(
|
|
110
|
+
boardDoc.collection('locks').doc('board-lock'),
|
|
111
|
+
{ holderId: options.holderId ?? randomUUID() },
|
|
112
|
+
),
|
|
113
|
+
|
|
114
|
+
// ── Utilities ─────────────────────────────────────────────────────────
|
|
115
|
+
hashFn(value) {
|
|
116
|
+
return createHash('sha256').update(JSON.stringify(value)).digest('hex').slice(0, 16);
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
genId() {
|
|
120
|
+
return lexicalId();
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
requestProcessAccumulated: options.requestProcessAccumulated,
|
|
124
|
+
publishBoardChangeNotifications: options.publishBoardChangeNotifications,
|
|
125
|
+
onWarn: (msg) => console.warn(`[firestore-board-adapter:${boardId}] ${msg}`),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* firestore-journal-storage.js
|
|
3
|
+
*
|
|
4
|
+
* AsyncJournalStorage backed by a Firestore CollectionReference.
|
|
5
|
+
* Each entry is stored as: { id, createdAt, payload }
|
|
6
|
+
* IDs are lexicographically sortable (timestamp prefix) so ORDER BY id
|
|
7
|
+
* gives insertion order.
|
|
8
|
+
*
|
|
9
|
+
* @param {import('@google-cloud/firestore').CollectionReference} col
|
|
10
|
+
* @returns {import('yaml-flow/cloud-storage').AsyncJournalStorage}
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
function lexicalId() {
|
|
14
|
+
const ts = String(Date.now()).padStart(13, '0');
|
|
15
|
+
const rand = Math.random().toString(36).slice(2, 10).padEnd(8, '0');
|
|
16
|
+
return `${ts}-${rand}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function createFirestoreJournalStorage(col) {
|
|
20
|
+
return {
|
|
21
|
+
async append(payload) {
|
|
22
|
+
const id = lexicalId();
|
|
23
|
+
const doc = { id, createdAt: new Date().toISOString(), payload };
|
|
24
|
+
await col.doc(id).set(doc);
|
|
25
|
+
return { id, payload };
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
async readAll() {
|
|
29
|
+
const snap = await col.orderBy('id').get();
|
|
30
|
+
return snap.docs.map((d) => ({ id: d.data().id, payload: d.data().payload }));
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
async readAfter(cursor) {
|
|
34
|
+
let q = col.orderBy('id');
|
|
35
|
+
if (cursor) q = col.where('id', '>', cursor).orderBy('id');
|
|
36
|
+
const snap = await q.get();
|
|
37
|
+
const entries = snap.docs.map((d) => ({ id: d.data().id, payload: d.data().payload }));
|
|
38
|
+
return {
|
|
39
|
+
entries,
|
|
40
|
+
newCursor: entries.length > 0 ? entries[entries.length - 1].id : cursor,
|
|
41
|
+
};
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
async clear() {
|
|
45
|
+
const snap = await col.get();
|
|
46
|
+
const batchSize = 500;
|
|
47
|
+
for (let i = 0; i < snap.docs.length; i += batchSize) {
|
|
48
|
+
const batch = col.firestore.batch();
|
|
49
|
+
for (const doc of snap.docs.slice(i, i + batchSize)) batch.delete(doc.ref);
|
|
50
|
+
await batch.commit();
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* firestore-kv-storage.js
|
|
3
|
+
*
|
|
4
|
+
* AsyncKVStorage backed by a Firestore CollectionReference.
|
|
5
|
+
* Each key is stored as a document with fields: { k, value }
|
|
6
|
+
* where k = original key string, value = arbitrary JSON value.
|
|
7
|
+
*
|
|
8
|
+
* Document ID: base64url(key) — avoids '/' and other Firestore-forbidden chars.
|
|
9
|
+
*
|
|
10
|
+
* @param {import('@google-cloud/firestore').CollectionReference} col
|
|
11
|
+
* @returns {import('yaml-flow/cloud-storage').AsyncKVStorage}
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
function encodeDocId(key) {
|
|
15
|
+
return Buffer.from(key).toString('base64url');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function decodeDocId(docId) {
|
|
19
|
+
return Buffer.from(docId, 'base64url').toString();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createFirestoreKvStorage(col) {
|
|
23
|
+
return {
|
|
24
|
+
async read(key) {
|
|
25
|
+
const snap = await col.doc(encodeDocId(key)).get();
|
|
26
|
+
if (!snap.exists) return null;
|
|
27
|
+
return snap.data()?.value ?? null;
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
async write(key, value) {
|
|
31
|
+
await col.doc(encodeDocId(key)).set({ k: key, value });
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
async delete(key) {
|
|
35
|
+
await col.doc(encodeDocId(key)).delete();
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
async listKeys(prefix = '') {
|
|
39
|
+
let q = col;
|
|
40
|
+
if (prefix) {
|
|
41
|
+
q = col.where('k', '>=', prefix).where('k', '<', prefix + '\uf8ff');
|
|
42
|
+
}
|
|
43
|
+
const snap = await q.orderBy('k').get();
|
|
44
|
+
return snap.docs.map((d) => d.data().k ?? decodeDocId(d.id));
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* firestore-lock.js
|
|
3
|
+
*
|
|
4
|
+
* AsyncAtomicRelayLock backed by a Firestore document + runTransaction.
|
|
5
|
+
*
|
|
6
|
+
* Document shape: { held, holderId, expiresAt, acquiredAt }
|
|
7
|
+
* Guarantees at-most-one holder via Firestore transaction atomicity.
|
|
8
|
+
*
|
|
9
|
+
* @param {import('@google-cloud/firestore').DocumentReference} lockDoc
|
|
10
|
+
* @param {{ holderId?: string, ttlMs?: number }} [options]
|
|
11
|
+
* @returns {import('yaml-flow/cloud-storage').AsyncAtomicRelayLock}
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { randomUUID } from 'node:crypto';
|
|
15
|
+
|
|
16
|
+
export function createFirestoreLock(lockDoc, options = {}) {
|
|
17
|
+
const holderId = options.holderId ?? randomUUID();
|
|
18
|
+
const ttlMs = options.ttlMs ?? 30_000;
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
async tryAcquire() {
|
|
22
|
+
let released = false;
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
await lockDoc.firestore.runTransaction(async (tx) => {
|
|
26
|
+
const snap = await tx.get(lockDoc);
|
|
27
|
+
const now = new Date();
|
|
28
|
+
if (snap.exists) {
|
|
29
|
+
const d = snap.data();
|
|
30
|
+
if (d.held && d.expiresAt > now.toISOString()) {
|
|
31
|
+
throw Object.assign(new Error('locked'), { code: 'locked' });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
tx.set(lockDoc, {
|
|
35
|
+
held: true,
|
|
36
|
+
holderId,
|
|
37
|
+
expiresAt: new Date(now.getTime() + ttlMs).toISOString(),
|
|
38
|
+
acquiredAt: now.toISOString(),
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
} catch (err) {
|
|
42
|
+
if (err?.code === 'locked') return null;
|
|
43
|
+
throw err;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return async () => {
|
|
47
|
+
if (released) return;
|
|
48
|
+
released = true;
|
|
49
|
+
try {
|
|
50
|
+
await lockDoc.firestore.runTransaction(async (tx) => {
|
|
51
|
+
const snap = await tx.get(lockDoc);
|
|
52
|
+
if (snap.exists && snap.data().holderId === holderId) {
|
|
53
|
+
tx.update(lockDoc, { held: false, holderId: null });
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
} catch {
|
|
57
|
+
// Best-effort release — lock will expire via TTL
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* firestore-queue-storage.js
|
|
3
|
+
*
|
|
4
|
+
* AsyncQueueStorage backed by Firestore with visibility-timeout leasing.
|
|
5
|
+
*
|
|
6
|
+
* Document shape:
|
|
7
|
+
* { id, body, enqueuedAt, attempt, visibleAfter, leaseToken, leaseExpiresAt,
|
|
8
|
+
* dead, deadReason, dedupKey? }
|
|
9
|
+
*
|
|
10
|
+
* Leasing is done via Firestore transactions: find visible messages,
|
|
11
|
+
* then atomically claim each one (optimistic concurrency — skip on race).
|
|
12
|
+
*
|
|
13
|
+
* Required Firestore composite index for the lease query:
|
|
14
|
+
* Collection group: <your collection>
|
|
15
|
+
* Fields: dead ASC, visibleAfter ASC
|
|
16
|
+
*
|
|
17
|
+
* @param {import('@google-cloud/firestore').CollectionReference} col
|
|
18
|
+
* @param {{ defaultVisibilityMs?: number }} [options]
|
|
19
|
+
* @returns {import('yaml-flow/cloud-storage').AsyncQueueStorage}
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { randomUUID } from 'node:crypto';
|
|
23
|
+
|
|
24
|
+
function lexicalId() {
|
|
25
|
+
const ts = String(Date.now()).padStart(13, '0');
|
|
26
|
+
const rand = Math.random().toString(36).slice(2, 10).padEnd(8, '0');
|
|
27
|
+
return `${ts}-${rand}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function createFirestoreQueueStorage(col, options = {}) {
|
|
31
|
+
const defaultVisibilityMs = options.defaultVisibilityMs ?? 30_000;
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
async enqueue(body) {
|
|
35
|
+
const id = lexicalId();
|
|
36
|
+
const now = new Date().toISOString();
|
|
37
|
+
await col.doc(id).set({
|
|
38
|
+
id,
|
|
39
|
+
body,
|
|
40
|
+
enqueuedAt: now,
|
|
41
|
+
attempt: 0,
|
|
42
|
+
visibleAfter: now,
|
|
43
|
+
leaseToken: null,
|
|
44
|
+
leaseExpiresAt: null,
|
|
45
|
+
dead: false,
|
|
46
|
+
deadReason: null,
|
|
47
|
+
});
|
|
48
|
+
return { id, body, enqueuedAt: now };
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
async enqueueIfAbsent(body, dedupKey) {
|
|
52
|
+
const existing = await col
|
|
53
|
+
.where('dedupKey', '==', dedupKey)
|
|
54
|
+
.where('dead', '==', false)
|
|
55
|
+
.limit(1)
|
|
56
|
+
.get();
|
|
57
|
+
if (!existing.empty) return null;
|
|
58
|
+
const id = lexicalId();
|
|
59
|
+
const now = new Date().toISOString();
|
|
60
|
+
await col.doc(id).set({
|
|
61
|
+
id,
|
|
62
|
+
body,
|
|
63
|
+
enqueuedAt: now,
|
|
64
|
+
attempt: 0,
|
|
65
|
+
visibleAfter: now,
|
|
66
|
+
leaseToken: null,
|
|
67
|
+
leaseExpiresAt: null,
|
|
68
|
+
dead: false,
|
|
69
|
+
deadReason: null,
|
|
70
|
+
dedupKey,
|
|
71
|
+
});
|
|
72
|
+
return { id, body, enqueuedAt: now };
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
async lease(opts = {}) {
|
|
76
|
+
const max = opts.max ?? 1;
|
|
77
|
+
const visibilityMs = opts.visibilityMs ?? defaultVisibilityMs;
|
|
78
|
+
const nowIso = new Date().toISOString();
|
|
79
|
+
|
|
80
|
+
// Find visible, non-dead messages — fetch extra to tolerate races
|
|
81
|
+
const snap = await col
|
|
82
|
+
.where('dead', '==', false)
|
|
83
|
+
.where('visibleAfter', '<=', nowIso)
|
|
84
|
+
.orderBy('visibleAfter')
|
|
85
|
+
.limit(max * 4)
|
|
86
|
+
.get();
|
|
87
|
+
|
|
88
|
+
const leased = [];
|
|
89
|
+
for (const doc of snap.docs) {
|
|
90
|
+
if (leased.length >= max) break;
|
|
91
|
+
|
|
92
|
+
const data = doc.data();
|
|
93
|
+
// Skip if currently leased and lease hasn't expired
|
|
94
|
+
if (data.leaseToken && data.leaseExpiresAt > nowIso) continue;
|
|
95
|
+
|
|
96
|
+
const leaseToken = randomUUID();
|
|
97
|
+
const leaseExpiresAt = new Date(Date.now() + visibilityMs).toISOString();
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
let claimedAttempt = 0;
|
|
101
|
+
await col.firestore.runTransaction(async (tx) => {
|
|
102
|
+
const fresh = await tx.get(doc.ref);
|
|
103
|
+
if (!fresh.exists) throw Object.assign(new Error('gone'), { code: 'gone' });
|
|
104
|
+
const d = fresh.data();
|
|
105
|
+
if (d.dead) throw Object.assign(new Error('dead'), { code: 'dead' });
|
|
106
|
+
if (d.leaseToken && d.leaseExpiresAt > new Date().toISOString()) {
|
|
107
|
+
throw Object.assign(new Error('taken'), { code: 'taken' });
|
|
108
|
+
}
|
|
109
|
+
claimedAttempt = (d.attempt ?? 0) + 1;
|
|
110
|
+
tx.update(doc.ref, { leaseToken, leaseExpiresAt, attempt: claimedAttempt });
|
|
111
|
+
});
|
|
112
|
+
leased.push({
|
|
113
|
+
id: data.id,
|
|
114
|
+
body: data.body,
|
|
115
|
+
enqueuedAt: data.enqueuedAt,
|
|
116
|
+
attempt: claimedAttempt,
|
|
117
|
+
leaseToken,
|
|
118
|
+
leaseExpiresAt,
|
|
119
|
+
});
|
|
120
|
+
} catch {
|
|
121
|
+
// Race condition — skip this message
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return leased;
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
async ack(messageId, leaseToken) {
|
|
128
|
+
try {
|
|
129
|
+
await col.firestore.runTransaction(async (tx) => {
|
|
130
|
+
const ref = col.doc(messageId);
|
|
131
|
+
const snap = await tx.get(ref);
|
|
132
|
+
if (!snap.exists) return;
|
|
133
|
+
if (snap.data().leaseToken !== leaseToken) throw new Error('token mismatch');
|
|
134
|
+
tx.delete(ref);
|
|
135
|
+
});
|
|
136
|
+
return true;
|
|
137
|
+
} catch {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
async nack(messageId, leaseToken, opts = {}) {
|
|
143
|
+
try {
|
|
144
|
+
await col.firestore.runTransaction(async (tx) => {
|
|
145
|
+
const ref = col.doc(messageId);
|
|
146
|
+
const snap = await tx.get(ref);
|
|
147
|
+
if (!snap.exists) return;
|
|
148
|
+
if (snap.data().leaseToken !== leaseToken) throw new Error('token mismatch');
|
|
149
|
+
if (opts.dead) {
|
|
150
|
+
tx.update(ref, {
|
|
151
|
+
dead: true,
|
|
152
|
+
deadReason: opts.reason ?? 'nacked',
|
|
153
|
+
leaseToken: null,
|
|
154
|
+
leaseExpiresAt: null,
|
|
155
|
+
});
|
|
156
|
+
} else {
|
|
157
|
+
tx.update(ref, {
|
|
158
|
+
leaseToken: null,
|
|
159
|
+
leaseExpiresAt: null,
|
|
160
|
+
visibleAfter: new Date().toISOString(),
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
return true;
|
|
165
|
+
} catch {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
async peekActive(prefix) {
|
|
171
|
+
const snap = await col.where('dead', '==', false).orderBy('enqueuedAt').get();
|
|
172
|
+
return snap.docs
|
|
173
|
+
.map((d) => d.data())
|
|
174
|
+
.filter((d) => !prefix || String(d.id).startsWith(prefix))
|
|
175
|
+
.map((d) => ({ id: d.id, body: d.body, enqueuedAt: d.enqueuedAt }));
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
async peekDeadLetter(prefix) {
|
|
179
|
+
const snap = await col.where('dead', '==', true).orderBy('enqueuedAt').get();
|
|
180
|
+
return snap.docs
|
|
181
|
+
.map((d) => d.data())
|
|
182
|
+
.filter((d) => !prefix || String(d.id).startsWith(prefix))
|
|
183
|
+
.map((d) => ({ id: d.id, body: d.body, enqueuedAt: d.enqueuedAt, reason: d.deadReason }));
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* firestore-scratch-storage.js
|
|
3
|
+
*
|
|
4
|
+
* AsyncScratchStorage backed by Firestore.
|
|
5
|
+
* Extends AsyncBlobStorage with ephemeral-key creation and a config sub-map.
|
|
6
|
+
*
|
|
7
|
+
* @param {import('@google-cloud/firestore').CollectionReference} col
|
|
8
|
+
* @returns {import('yaml-flow/cloud-storage').AsyncScratchStorage}
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createFirestoreBlobStorage } from './firestore-blob-storage.js';
|
|
12
|
+
|
|
13
|
+
function lexicalId() {
|
|
14
|
+
const ts = String(Date.now()).padStart(13, '0');
|
|
15
|
+
const rand = Math.random().toString(36).slice(2, 10).padEnd(8, '0');
|
|
16
|
+
return `${ts}-${rand}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function createFirestoreScratchStorage(col) {
|
|
20
|
+
const blob = createFirestoreBlobStorage(col);
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
...blob,
|
|
24
|
+
|
|
25
|
+
async getUniqueKey(prefix = 'scratch-', suffix = '') {
|
|
26
|
+
return `${prefix}${lexicalId()}${suffix}`;
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
async create(data, prefix = 'scratch-', suffix = '') {
|
|
30
|
+
const key = `${prefix}${lexicalId()}${suffix}`;
|
|
31
|
+
await blob.write(key, data);
|
|
32
|
+
return key;
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
keyRef(key) {
|
|
36
|
+
return { kind: 'firestore-blob', value: `${col.path}/${Buffer.from(key).toString('base64url')}` };
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
config: {
|
|
40
|
+
async get(k) {
|
|
41
|
+
const content = await blob.read(`__config__/${k}`);
|
|
42
|
+
if (content == null) return null;
|
|
43
|
+
try { return JSON.parse(content); } catch { return content; }
|
|
44
|
+
},
|
|
45
|
+
async set(k, v) {
|
|
46
|
+
await blob.write(`__config__/${k}`, JSON.stringify(v));
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|