diff --git a/bun.lock b/bun.lock index 105aff2..7a529c0 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "name": "textile", "dependencies": { "@ax-llm/ax": "^14.0.39", - "@deepfates/lync": "file:vendor/deepfates-lync-0.4.3-0a511b6.tgz", + "@deepfates/lync": "file:vendor/deepfates-lync-0.4.3-828a140.tgz", "@tailwindcss/postcss": "^4.1.17", "@types/d3-flextree": "^2.1.4", "@types/express": "^4.17.21", @@ -45,6 +45,7 @@ "eslint": "^8.57.0", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.6", + "fake-indexeddb": "^6.2.4", "playwright": "^1.59.1", "typescript": "^5.2.2", "vite": "^7.3.6", @@ -250,7 +251,7 @@ "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], - "@deepfates/lync": ["@deepfates/lync@vendor/deepfates-lync-0.4.3-0a511b6.tgz", { "bin": { "lync": "bin/lync.js" } }], + "@deepfates/lync": ["@deepfates/lync@vendor/deepfates-lync-0.4.3-828a140.tgz", { "bin": { "lync": "bin/lync.js" } }], "@emotion/is-prop-valid": ["@emotion/is-prop-valid@1.4.0", "", { "dependencies": { "@emotion/memoize": "^0.9.0" } }, "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw=="], @@ -736,6 +737,8 @@ "express": ["express@4.22.2", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "~1.20.5", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "~1.3.1", "fresh": "~0.5.2", "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", "qs": "~6.15.1", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", "serve-static": "~1.16.2", "setprototypeof": "1.2.0", "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q=="], + "fake-indexeddb": ["fake-indexeddb@6.2.5", "", {}, "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], diff --git a/client/interface/lync/__tests__/indexedDbDurability.test.ts b/client/interface/lync/__tests__/indexedDbDurability.test.ts new file mode 100644 index 0000000..bef28c4 --- /dev/null +++ b/client/interface/lync/__tests__/indexedDbDurability.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "bun:test"; +import { IDBFactory } from "fake-indexeddb"; +import { createIndexedDbEventStore } from "@deepfates/lync/idb-log"; +import type { LyncEventBody } from "@deepfates/lync/events"; +import { serializeLyncEvent } from "@deepfates/lync/store"; + +const at = "2026-09-06T21:31:13.000Z"; + +function event( + id: string, + kind: string, + parents: string[] = [], + payload: Record = {}, +): LyncEventBody { + return { + v: 1, + id, + kind, + at, + author: { actor: "textile-durability-regression" }, + parents, + payload, + }; +} + +describe("installed Lync IndexedDB durability", () => { + it("reopens every resolved curation mutation while an archive batch is suspended", async () => { + const indexedDB = new IDBFactory(); + const dbName = "textile-import-curation-overlap"; + const store = createIndexedDbEventStore({ dbName, indexedDB }); + const importedRoot = event( + "019fcb10-0000-7000-8000-000000000001", + "twitter/tweet", + [], + { full_text: "Imported archive root" }, + ); + const importedReply = event( + "019fcb10-0000-7000-8000-000000000002", + "twitter/tweet", + [importedRoot.id], + { full_text: "Imported archive reply" }, + ); + const keep = event( + "019fcb10-0000-7000-8000-000000000003", + "lync/mark", + [importedRoot.id], + { label: "selection", target: importedRoot.id }, + ); + const note = event( + "019fcb10-0000-7000-8000-000000000004", + "curare/annotation", + [importedRoot.id], + { label: "note", text: "Keep this source context." }, + ); + const cluster = event( + "019fcb10-0000-7000-8000-000000000005", + "lync/mark", + [importedRoot.id], + { label: "cluster", target: importedRoot.id }, + ); + + let announceSuspension!: () => void; + let releaseImport!: () => void; + const suspended = new Promise((resolve) => { + announceSuspension = resolve; + }); + const released = new Promise((resolve) => { + releaseImport = resolve; + }); + async function* archiveImport() { + yield importedRoot; + announceSuspension(); + await released; + yield importedReply; + } + + const importing = store.appendMany(archiveImport()); + await suspended; + + const expectDurableAfterReopen = async (id: string) => { + const reopened = createIndexedDbEventStore({ dbName, indexedDB }); + await expect(reopened.byId(id)).resolves.toMatchObject({ body: { id } }); + }; + + try { + await expect(store.append(keep)).resolves.toMatchObject({ status: "added" }); + await expectDurableAfterReopen(keep.id); + + await expect(store.union(serializeLyncEvent(note))).resolves.toMatchObject({ + status: "added", + }); + await expectDurableAfterReopen(note.id); + + await expect(store.appendMany([cluster])).resolves.toEqual([ + expect.objectContaining({ status: "added" }), + ]); + await expectDurableAfterReopen(cluster.id); + } finally { + releaseImport(); + await importing; + } + + const reopened = createIndexedDbEventStore({ dbName, indexedDB }); + await expect(reopened.byRoot(importedRoot.id)).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ body: expect.objectContaining({ id: importedRoot.id }) }), + expect.objectContaining({ body: expect.objectContaining({ id: importedReply.id }) }), + expect.objectContaining({ body: expect.objectContaining({ id: keep.id }) }), + expect.objectContaining({ body: expect.objectContaining({ id: note.id }) }), + expect.objectContaining({ body: expect.objectContaining({ id: cluster.id }) }), + ]), + ); + }); +}); diff --git a/package.json b/package.json index 7ac2013..645c831 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ }, "dependencies": { "@ax-llm/ax": "^14.0.39", - "@deepfates/lync": "file:vendor/deepfates-lync-0.4.3-0a511b6.tgz", + "@deepfates/lync": "file:vendor/deepfates-lync-0.4.3-828a140.tgz", "@tailwindcss/postcss": "^4.1.17", "@types/d3-flextree": "^2.1.4", "@types/express": "^4.17.21", @@ -60,6 +60,7 @@ "eslint": "^8.57.0", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.6", + "fake-indexeddb": "^6.2.4", "playwright": "^1.59.1", "typescript": "^5.2.2", "vite": "^7.3.6", diff --git a/vendor/LYNC-PROVENANCE.md b/vendor/LYNC-PROVENANCE.md index 612e14b..9cecd7a 100644 --- a/vendor/LYNC-PROVENANCE.md +++ b/vendor/LYNC-PROVENANCE.md @@ -1,14 +1,24 @@ # Vendored Lync package -`deepfates-lync-0.4.3-0a511b6.tgz` was produced with `pnpm pack` +`deepfates-lync-0.4.3-828a140.tgz` was produced once with `pnpm pack` from the clean `@deepfates/lync` checkout at commit -`0a511b6f918acf87800bf433f34311ee47a9c48b`. +`828a14017ea71519c510b0808a49d6382a36ed33` (tree +`f24e6122012f24beea934a1f639117ab15694725`). Sibling consumers reuse these +exact archive bytes and checksum rather than repacking the source. - Declared package version: `0.4.3` -- Archive SHA-256: `4de871ce1117cff040fedc069944c3705e314168e01f151ae6d27239f6d1b745` +- Archive SHA-256: `cc90bfc8766ecab7c7ab53298ca0fdff8e3649626879f6cab7300c071e566196` - Required unpublished surfaces: `@deepfates/lync/indexed-union` and `@deepfates/lync/presentation` +The source verification gate passed 223 tests across 26 files, typecheck, +executable examples, and its packed-artifact check. Its bounded regressions +cover public mutation completion during an overlapping async batch, reentrant +batch producers, partial file-store writes, and relay retry/reopen after +partial writes. Textile separately exercises the installed archive through an +IndexedDB fresh-store reopen regression and its provider-free browser path. +These checks establish those cases, not a blanket durability guarantee. + This exact local package keeps Textile reproducible before an owner-approved 0.4 registry release. It is a dependency boundary, not a fork: union parsing, conflict adjudication, topology, suppression, source-byte verification, and diff --git a/vendor/deepfates-lync-0.4.3-0a511b6.tgz b/vendor/deepfates-lync-0.4.3-0a511b6.tgz deleted file mode 100644 index 79716da..0000000 Binary files a/vendor/deepfates-lync-0.4.3-0a511b6.tgz and /dev/null differ diff --git a/vendor/deepfates-lync-0.4.3-828a140.tgz b/vendor/deepfates-lync-0.4.3-828a140.tgz new file mode 100644 index 0000000..7452bc1 Binary files /dev/null and b/vendor/deepfates-lync-0.4.3-828a140.tgz differ