@ai-matrx/records 0.86.2 → 0.87.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/CHANGELOG.md +30 -0
- package/dist/core/index.cjs +6 -4
- package/dist/core/index.cjs.map +1 -1
- package/dist/core/index.js +6 -4
- package/dist/core/index.js.map +1 -1
- package/dist/data/index.cjs +19 -6
- package/dist/data/index.cjs.map +1 -1
- package/dist/data/index.d.cts +13 -0
- package/dist/data/index.d.ts +13 -0
- package/dist/data/index.js +19 -6
- package/dist/data/index.js.map +1 -1
- package/dist/data/react.d.cts +13 -0
- package/dist/data/react.d.ts +13 -0
- package/dist/index.cjs +2 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +0 -34
- package/dist/index.d.ts +0 -34
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/list/index.cjs.map +1 -1
- package/dist/list/index.d.cts +5 -9
- package/dist/list/index.d.ts +5 -9
- package/dist/list/index.js.map +1 -1
- package/dist/react/index.cjs +20 -7
- package/dist/react/index.cjs.map +1 -1
- package/dist/react/index.d.cts +2 -36
- package/dist/react/index.d.ts +2 -36
- package/dist/react/index.js +20 -7
- package/dist/react/index.js.map +1 -1
- package/dist/realtime/index.cjs +2 -1
- package/dist/realtime/index.cjs.map +1 -1
- package/dist/realtime/index.js +2 -1
- package/dist/realtime/index.js.map +1 -1
- package/dist/typed-table/index.cjs +7 -5
- package/dist/typed-table/index.cjs.map +1 -1
- package/dist/typed-table/index.d.cts +2 -2
- package/dist/typed-table/index.d.ts +2 -2
- package/dist/typed-table/index.js +7 -5
- package/dist/typed-table/index.js.map +1 -1
- package/package.json +3 -3
package/dist/realtime/index.cjs
CHANGED
|
@@ -31,8 +31,9 @@ module.exports = __toCommonJS(realtime_exports);
|
|
|
31
31
|
var import_realtime = require("@ai-matrx/realtime");
|
|
32
32
|
|
|
33
33
|
// src/ops.ts
|
|
34
|
+
var import_global_slot = require("@ai-matrx/kit/global-slot");
|
|
34
35
|
var REMEMBER_MS = 3e4;
|
|
35
|
-
var ours = /* @__PURE__ */ new Map();
|
|
36
|
+
var ours = (0, import_global_slot.globalSlot)("ai-matrx.records.op-ledger", () => /* @__PURE__ */ new Map());
|
|
36
37
|
function isOwnOp(opId) {
|
|
37
38
|
if (!opId) return false;
|
|
38
39
|
const at = ours.get(opId);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/realtime/index.ts","../../src/ops.ts"],"sourcesContent":["// src/realtime/index.ts — @ai-matrx/records/realtime\n//\n// THE STORE'S LIVE-UPDATES PORT, ONCE, FOR EVERY HOST (Applets AP-3, 2026-10-06).\n//\n// Until now `RecordsRealtimePort` was a contract with no implementation in the package: matrx-frontend\n// wrote its own (features/unified-data/realtime/recordsRealtimePort.ts) and every other host — the\n// Applets platform host first — would have had to write a second, or go without and never fire a\n// `rows:<alias>` event. This is that implementation, moved here so every host binds the same one.\n//\n// HOW THE STORE GOES LIVE. Schema `custom` is doors-only (zero table grants) and `custom.record` is\n// hash-partitioned, so `postgres_changes` cannot serve it. The database BROADCASTS a notice instead,\n// on a PRIVATE topic per Table — `custom:table:<table_id>`, event `records.changed`, written by\n// `custom.io_outbox_broadcast_stmt` and admitted by the one RLS policy on `realtime.messages`, which\n// asks the store's own ladder (`custom.assert_may_know_table`). This file joins that topic through\n// `@ai-matrx/realtime` — the ONE realtime layer (never a hand-written `.channel(`) — so the socket,\n// `setAuth` before a private join, backoff, dedupe and wake/reconnect backfill are the manager's.\n//\n// WHAT COMES DOWN THE WIRE IS NOT DATA: `{table_id, kind, op, op_id, record_ids, fields_changed, at}`\n// — ids, never values. The hooks re-read the named ids through the read door, which applies the ladder.\n//\n// · ECHO SUPPRESSION BY OP ID — every write through this package mints `_op_id`; the store returns it\n// as the notice's `op_id`; `isOwnOp` drops our own write's echo before anything else.\n// · ONE CHANNEL PER TABLE, HOWEVER MANY READERS — opened by the first listener, closed by the last;\n// each listener coalesces its own bursts.\n// · A COLUMN IS NOT A ROW — a `field` / `table` notice calls `sink.shape()`.\n// · NO REPLAY ON A WAKE OR RECONNECT — the only correct answer is `sink.records(null)` (read again).\n\nimport {\n defineChannelNamespace,\n subscribeToRealtimeManager,\n type ChannelSpec,\n type RealtimeManager,\n} from \"@ai-matrx/realtime\";\n\nimport { isOwnOp } from \"../ops\";\nimport type { RecordsChangeSink, RecordsRealtimePort } from \"../ports\";\nimport type { Uuid } from \"../record\";\n\n/**\n * The topic is a CONTRACT WITH THE DATABASE (a Postgres trigger writes it, an RLS policy parses it),\n * so it is built in one place through the realtime package's foreign-topic door.\n */\nexport const storeTableChannel = defineChannelNamespace({\n namespace: \"records-store-table\",\n foreignTopic: \"custom:table\",\n parts: [\"tableId\"],\n description:\n \"One Table in the record store. Carries NOTICES written by custom.io_outbox_broadcast_stmt — which record ids moved and whether the table's shape moved — never any value. Authorized by the one RLS policy on realtime.messages, which asks the store's own ladder.\",\n});\n\n/** The broadcast event the store sends on a table's topic. */\nexport const STORE_NOTICE_EVENT = \"records.changed\";\n\n/** Coalesce a burst into one re-read. A statement is already one notice; this catches two. */\nexport const NUDGE_DEBOUNCE_MS = 150;\n\nexport interface StoreNotice {\n table_id?: string;\n kind?: \"record\" | \"field\" | \"table\";\n op?: \"created\" | \"updated\" | \"deleted\";\n /** The client operation that caused it (`_op_id`), or null for a server, agent or foreign write. */\n op_id?: string | null;\n /** `null` MEANS \"re-read the page\" — above the cap the database stops listing ids. */\n record_ids?: string[] | null;\n fields_changed?: boolean;\n at?: string;\n}\n\n/** The notice's own identity: a redelivered notice is the same statement at the same instant. */\nexport function noticeKey(notice: StoreNotice): string | undefined {\n if (!notice.at) return undefined;\n const ids = notice.record_ids == null ? \"*\" : [...notice.record_ids].sort().join(\",\");\n return `${notice.table_id ?? \"\"}:${notice.kind ?? \"\"}:${notice.op ?? \"\"}:${notice.op_id ?? \"\"}:${notice.at}:${ids}`;\n}\n\ninterface TableListener {\n notice(notice: StoreNotice): void;\n backfill(): void;\n}\n\ninterface TableChannel {\n listeners: Set<TableListener>;\n stop: (() => void) | null;\n}\n\nexport interface CreateRecordsRealtimePortOptions {\n /**\n * The host's realtime manager (`@ai-matrx/realtime`). Left out, the AMBIENT manager is used — the one\n * the app mounted (`setAmbientRealtimeManager` / `<RealtimeProvider>`); the port joins when one is\n * mounted and follows it if it is replaced. Never a second socket of the port's own.\n */\n manager?: RealtimeManager;\n}\n\n/** The channel spec for one table: private, echo-guarded, deduped, backfilled. */\nexport function storeTableChannelSpec(tableId: string, onNotice: (notice: StoreNotice) => void, onBackfill: () => void): ChannelSpec {\n return {\n topic: storeTableChannel.topic({ tableId }),\n // Database Broadcast is authorized by RLS on realtime.messages — that is what `private` means here;\n // the manager awaits `setAuth()` before subscribing (its absence is a channel that looks healthy and\n // delivers nothing forever).\n private: true,\n // The sender is Postgres: no Matrx envelope. OUR OWN WRITE is known by the op id the write door carried.\n wire: { mode: \"raw\", isOwnMessage: (payload) => isOwnOp((payload as StoreNotice | null)?.op_id) },\n eventKey: (_source, payload) => noticeKey((payload ?? {}) as StoreNotice),\n broadcast: [\n {\n event: STORE_NOTICE_EVENT,\n onMessage: (message) => {\n const notice = (message.data ?? {}) as StoreNotice;\n if (isOwnOp(notice.op_id)) return; // belt and braces: the manager's test ran first\n onNotice(notice);\n },\n },\n ],\n // A CHANNEL WITH NO RECONCILIATION IS A SCREEN THAT WILL EVENTUALLY LIE: realtime has no replay.\n onBackfill: () => onBackfill(),\n };\n}\n\n/**\n * The store's live-updates port, bound to a realtime manager. Hand it to `RecordsConfig.realtime`; the\n * `@ai-matrx/records/data` port turns its sink calls into `rows:<alias>` events.\n */\nexport function createRecordsRealtimePort(options: CreateRecordsRealtimePortOptions = {}): RecordsRealtimePort {\n const channels = new Map<string, TableChannel>();\n\n const open = (tableId: string): TableChannel => {\n const channel: TableChannel = { listeners: new Set(), stop: null };\n const spec = storeTableChannelSpec(\n tableId,\n (notice) => {\n for (const listener of channel.listeners) listener.notice(notice);\n },\n () => {\n for (const listener of channel.listeners) listener.backfill();\n },\n );\n if (options.manager) {\n const handle = options.manager.open(spec);\n channel.stop = () => handle.close();\n } else {\n channel.stop = subscribeToRealtimeManager(() => spec);\n }\n return channel;\n };\n\n return {\n subscribeRecords({ table_id }, sink: RecordsChangeSink) {\n let timer: ReturnType<typeof setTimeout> | null = null;\n let pending = new Set<Uuid>();\n let wholePage = false;\n\n const flush = () => {\n timer = null;\n const ids = [...pending];\n const all = wholePage;\n pending = new Set();\n wholePage = false;\n // THE IDS ARE USED AS IDS. `null` is reserved for \"the store could not name them\" or \"the socket\n // was away\" and costs a whole read; a nudge naming three records re-reads three records.\n if (all) sink.records(null);\n else if (ids.length > 0) sink.records(ids);\n };\n const nudge = () => {\n if (timer === null) timer = setTimeout(flush, NUDGE_DEBOUNCE_MS);\n };\n\n const listener: TableListener = {\n notice(notice) {\n if (notice.kind === \"field\" || notice.kind === \"table\") sink.shape();\n if (notice.kind === \"field\") return;\n if (notice.record_ids == null) wholePage = true;\n else for (const id of notice.record_ids) pending.add(id);\n nudge();\n },\n backfill() {\n wholePage = true;\n flush();\n },\n };\n\n const channel = channels.get(table_id) ?? open(table_id);\n channels.set(table_id, channel);\n channel.listeners.add(listener);\n\n return () => {\n if (timer !== null) clearTimeout(timer);\n channel.listeners.delete(listener);\n if (channel.listeners.size > 0) return;\n // The last reader of this table left: the table's one subscription goes with it.\n channel.stop?.();\n channels.delete(table_id);\n };\n },\n };\n}\n","/**\n * THE CLIENT OPERATION LEDGER — how a browser recognises the echo of its own write.\n *\n * Every private-channel notice the record store broadcasts reaches EVERY browser watching that\n * Table, including the one that caused it. Until now the writer had no way to tell its own\n * change from a colleague's: the notice carried ids and nothing about who moved them, so\n * `recordsRealtimePort.ts` declared `acceptEchoFromSelf: true` and the writer re-read a page it\n * had already updated. It was named honestly rather than faked, and this is the other half.\n *\n * THE SHAPE. Every write through `RecordsClient` mints a uuid, sends it to the store as the\n * platform envelope key `_op_id`, and remembers it here. The store lifts that key out before\n * storage — it is never written onto the record — carries it on the change-feed row, and puts\n * it in the notice as `op_id`. A notice whose `op_id` is in this ledger is this browser's own\n * echo and is dropped.\n *\n * WHY A TIME WINDOW RATHER THAN A DELETE-ON-MATCH. One write can produce MORE THAN ONE notice:\n * a record write that also moves a column produces a `record` notice and a `field` notice, and\n * a statement touching two Tables produces one per Table. Forgetting an op the first time it\n * came back would make the second notice look like somebody else's and cost the re-read this\n * exists to avoid. So an op is remembered for a window and expires on its own.\n *\n * 🚨 THE WINDOW IS A CORRECTNESS BOUNDARY, NOT A TUNING KNOB, AND IT FAILS IN THE SAFE\n * DIRECTION BY CONSTRUCTION. Too SHORT and a late echo is treated as a stranger's change: one\n * extra read of a page that is already correct — invisible. Too LONG and a stranger's change\n * that happened to reuse our uuid would be dropped — which cannot happen, because it is a v4\n * uuid nobody else has ever seen. The only real cost of a long window is memory, so it is\n * bounded by COUNT as well as by time.\n *\n * WHAT THIS IS NOT. It is not a replacement for the store's optimistic concurrency, and it\n * never decides what is true — a dropped echo means \"I already know about this\", and every\n * path that could be wrong about that (a reconnect, a tab wake, a notice with no `op_id` at\n * all) re-reads through the door as it always did.\n */\nimport type { Uuid } from \"./core/index\";\n\n/** How long an operation stays recognisable as ours. See the header: it fails safe both ways. */\nconst REMEMBER_MS = 30_000;\n\n/** A ceiling so a long-lived tab that writes constantly cannot grow this without bound. */\nconst REMEMBER_MAX = 500;\n\n/** op id → the moment it was minted. Insertion-ordered, which is what makes the trim cheap. */\nconst ours = new Map<string, number>();\n\nfunction trim(now: number): void {\n // A Map iterates in insertion order and ids are minted in time order, so the first entry\n // that is still inside the window means every entry after it is too.\n for (const [id, at] of ours) {\n if (now - at <= REMEMBER_MS) break;\n ours.delete(id);\n }\n // Then the ceiling, oldest first, for a tab that writes faster than the window expires.\n while (ours.size > REMEMBER_MAX) {\n const oldest = ours.keys().next();\n if (oldest.done === true) break;\n ours.delete(oldest.value);\n }\n}\n\n/**\n * Mint the id for one client operation and remember it as ours.\n *\n * ONE CALL IS ONE OPERATION. A fifty-row paste through `recordWriteMany` is ONE op id, because\n * the store announces one statement once — and it REFUSES a batch carrying two different ones\n * by name, for exactly that reason.\n */\nexport function mintOpId(): Uuid {\n const id = crypto.randomUUID();\n const now = Date.now();\n ours.set(id, now);\n trim(now);\n return id as Uuid;\n}\n\n/**\n * Is this notice the echo of a write this browser made?\n *\n * `undefined`/`null` is NOT ours and never has been: a notice with no `op_id` came from a\n * server-side write, an agent tool, another client, or a store older than this mechanism —\n * all of which are changes this browser has not seen and must read.\n */\nexport function isOwnOp(opId: string | null | undefined): boolean {\n if (!opId) return false;\n const at = ours.get(opId);\n if (at === undefined) return false;\n if (Date.now() - at > REMEMBER_MS) {\n ours.delete(opId);\n return false;\n }\n return true;\n}\n\n/** Test seam: forget everything. Never called by product code. */\nexport function forgetAllOps(): void {\n ours.clear();\n}\n\n/** Test seam: how many operations are currently remembered. */\nexport function rememberedOpCount(): number {\n return ours.size;\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/realtime/index.ts","../../src/ops.ts"],"sourcesContent":["// src/realtime/index.ts — @ai-matrx/records/realtime\n//\n// THE STORE'S LIVE-UPDATES PORT, ONCE, FOR EVERY HOST (Applets AP-3, 2026-10-06).\n//\n// Until now `RecordsRealtimePort` was a contract with no implementation in the package: matrx-frontend\n// wrote its own (features/unified-data/realtime/recordsRealtimePort.ts) and every other host — the\n// Applets platform host first — would have had to write a second, or go without and never fire a\n// `rows:<alias>` event. This is that implementation, moved here so every host binds the same one.\n//\n// HOW THE STORE GOES LIVE. Schema `custom` is doors-only (zero table grants) and `custom.record` is\n// hash-partitioned, so `postgres_changes` cannot serve it. The database BROADCASTS a notice instead,\n// on a PRIVATE topic per Table — `custom:table:<table_id>`, event `records.changed`, written by\n// `custom.io_outbox_broadcast_stmt` and admitted by the one RLS policy on `realtime.messages`, which\n// asks the store's own ladder (`custom.assert_may_know_table`). This file joins that topic through\n// `@ai-matrx/realtime` — the ONE realtime layer (never a hand-written `.channel(`) — so the socket,\n// `setAuth` before a private join, backoff, dedupe and wake/reconnect backfill are the manager's.\n//\n// WHAT COMES DOWN THE WIRE IS NOT DATA: `{table_id, kind, op, op_id, record_ids, fields_changed, at}`\n// — ids, never values. The hooks re-read the named ids through the read door, which applies the ladder.\n//\n// · ECHO SUPPRESSION BY OP ID — every write through this package mints `_op_id`; the store returns it\n// as the notice's `op_id`; `isOwnOp` drops our own write's echo before anything else.\n// · ONE CHANNEL PER TABLE, HOWEVER MANY READERS — opened by the first listener, closed by the last;\n// each listener coalesces its own bursts.\n// · A COLUMN IS NOT A ROW — a `field` / `table` notice calls `sink.shape()`.\n// · NO REPLAY ON A WAKE OR RECONNECT — the only correct answer is `sink.records(null)` (read again).\n\nimport {\n defineChannelNamespace,\n subscribeToRealtimeManager,\n type ChannelSpec,\n type RealtimeManager,\n} from \"@ai-matrx/realtime\";\n\nimport { isOwnOp } from \"../ops\";\nimport type { RecordsChangeSink, RecordsRealtimePort } from \"../ports\";\nimport type { Uuid } from \"../record\";\n\n/**\n * The topic is a CONTRACT WITH THE DATABASE (a Postgres trigger writes it, an RLS policy parses it),\n * so it is built in one place through the realtime package's foreign-topic door.\n */\nexport const storeTableChannel = defineChannelNamespace({\n namespace: \"records-store-table\",\n foreignTopic: \"custom:table\",\n parts: [\"tableId\"],\n description:\n \"One Table in the record store. Carries NOTICES written by custom.io_outbox_broadcast_stmt — which record ids moved and whether the table's shape moved — never any value. Authorized by the one RLS policy on realtime.messages, which asks the store's own ladder.\",\n});\n\n/** The broadcast event the store sends on a table's topic. */\nexport const STORE_NOTICE_EVENT = \"records.changed\";\n\n/** Coalesce a burst into one re-read. A statement is already one notice; this catches two. */\nexport const NUDGE_DEBOUNCE_MS = 150;\n\nexport interface StoreNotice {\n table_id?: string;\n kind?: \"record\" | \"field\" | \"table\";\n op?: \"created\" | \"updated\" | \"deleted\";\n /** The client operation that caused it (`_op_id`), or null for a server, agent or foreign write. */\n op_id?: string | null;\n /** `null` MEANS \"re-read the page\" — above the cap the database stops listing ids. */\n record_ids?: string[] | null;\n fields_changed?: boolean;\n at?: string;\n}\n\n/** The notice's own identity: a redelivered notice is the same statement at the same instant. */\nexport function noticeKey(notice: StoreNotice): string | undefined {\n if (!notice.at) return undefined;\n const ids = notice.record_ids == null ? \"*\" : [...notice.record_ids].sort().join(\",\");\n return `${notice.table_id ?? \"\"}:${notice.kind ?? \"\"}:${notice.op ?? \"\"}:${notice.op_id ?? \"\"}:${notice.at}:${ids}`;\n}\n\ninterface TableListener {\n notice(notice: StoreNotice): void;\n backfill(): void;\n}\n\ninterface TableChannel {\n listeners: Set<TableListener>;\n stop: (() => void) | null;\n}\n\nexport interface CreateRecordsRealtimePortOptions {\n /**\n * The host's realtime manager (`@ai-matrx/realtime`). Left out, the AMBIENT manager is used — the one\n * the app mounted (`setAmbientRealtimeManager` / `<RealtimeProvider>`); the port joins when one is\n * mounted and follows it if it is replaced. Never a second socket of the port's own.\n */\n manager?: RealtimeManager;\n}\n\n/** The channel spec for one table: private, echo-guarded, deduped, backfilled. */\nexport function storeTableChannelSpec(tableId: string, onNotice: (notice: StoreNotice) => void, onBackfill: () => void): ChannelSpec {\n return {\n topic: storeTableChannel.topic({ tableId }),\n // Database Broadcast is authorized by RLS on realtime.messages — that is what `private` means here;\n // the manager awaits `setAuth()` before subscribing (its absence is a channel that looks healthy and\n // delivers nothing forever).\n private: true,\n // The sender is Postgres: no Matrx envelope. OUR OWN WRITE is known by the op id the write door carried.\n wire: { mode: \"raw\", isOwnMessage: (payload) => isOwnOp((payload as StoreNotice | null)?.op_id) },\n eventKey: (_source, payload) => noticeKey((payload ?? {}) as StoreNotice),\n broadcast: [\n {\n event: STORE_NOTICE_EVENT,\n onMessage: (message) => {\n const notice = (message.data ?? {}) as StoreNotice;\n if (isOwnOp(notice.op_id)) return; // belt and braces: the manager's test ran first\n onNotice(notice);\n },\n },\n ],\n // A CHANNEL WITH NO RECONCILIATION IS A SCREEN THAT WILL EVENTUALLY LIE: realtime has no replay.\n onBackfill: () => onBackfill(),\n };\n}\n\n/**\n * The store's live-updates port, bound to a realtime manager. Hand it to `RecordsConfig.realtime`; the\n * `@ai-matrx/records/data` port turns its sink calls into `rows:<alias>` events.\n */\nexport function createRecordsRealtimePort(options: CreateRecordsRealtimePortOptions = {}): RecordsRealtimePort {\n const channels = new Map<string, TableChannel>();\n\n const open = (tableId: string): TableChannel => {\n const channel: TableChannel = { listeners: new Set(), stop: null };\n const spec = storeTableChannelSpec(\n tableId,\n (notice) => {\n for (const listener of channel.listeners) listener.notice(notice);\n },\n () => {\n for (const listener of channel.listeners) listener.backfill();\n },\n );\n if (options.manager) {\n const handle = options.manager.open(spec);\n channel.stop = () => handle.close();\n } else {\n channel.stop = subscribeToRealtimeManager(() => spec);\n }\n return channel;\n };\n\n return {\n subscribeRecords({ table_id }, sink: RecordsChangeSink) {\n let timer: ReturnType<typeof setTimeout> | null = null;\n let pending = new Set<Uuid>();\n let wholePage = false;\n\n const flush = () => {\n timer = null;\n const ids = [...pending];\n const all = wholePage;\n pending = new Set();\n wholePage = false;\n // THE IDS ARE USED AS IDS. `null` is reserved for \"the store could not name them\" or \"the socket\n // was away\" and costs a whole read; a nudge naming three records re-reads three records.\n if (all) sink.records(null);\n else if (ids.length > 0) sink.records(ids);\n };\n const nudge = () => {\n if (timer === null) timer = setTimeout(flush, NUDGE_DEBOUNCE_MS);\n };\n\n const listener: TableListener = {\n notice(notice) {\n if (notice.kind === \"field\" || notice.kind === \"table\") sink.shape();\n if (notice.kind === \"field\") return;\n if (notice.record_ids == null) wholePage = true;\n else for (const id of notice.record_ids) pending.add(id);\n nudge();\n },\n backfill() {\n wholePage = true;\n flush();\n },\n };\n\n const channel = channels.get(table_id) ?? open(table_id);\n channels.set(table_id, channel);\n channel.listeners.add(listener);\n\n return () => {\n if (timer !== null) clearTimeout(timer);\n channel.listeners.delete(listener);\n if (channel.listeners.size > 0) return;\n // The last reader of this table left: the table's one subscription goes with it.\n channel.stop?.();\n channels.delete(table_id);\n };\n },\n };\n}\n","/**\n * THE CLIENT OPERATION LEDGER — how a browser recognises the echo of its own write.\n *\n * Every private-channel notice the record store broadcasts reaches EVERY browser watching that\n * Table, including the one that caused it. Until now the writer had no way to tell its own\n * change from a colleague's: the notice carried ids and nothing about who moved them, so\n * `recordsRealtimePort.ts` declared `acceptEchoFromSelf: true` and the writer re-read a page it\n * had already updated. It was named honestly rather than faked, and this is the other half.\n *\n * THE SHAPE. Every write through `RecordsClient` mints a uuid, sends it to the store as the\n * platform envelope key `_op_id`, and remembers it here. The store lifts that key out before\n * storage — it is never written onto the record — carries it on the change-feed row, and puts\n * it in the notice as `op_id`. A notice whose `op_id` is in this ledger is this browser's own\n * echo and is dropped.\n *\n * WHY A TIME WINDOW RATHER THAN A DELETE-ON-MATCH. One write can produce MORE THAN ONE notice:\n * a record write that also moves a column produces a `record` notice and a `field` notice, and\n * a statement touching two Tables produces one per Table. Forgetting an op the first time it\n * came back would make the second notice look like somebody else's and cost the re-read this\n * exists to avoid. So an op is remembered for a window and expires on its own.\n *\n * 🚨 THE WINDOW IS A CORRECTNESS BOUNDARY, NOT A TUNING KNOB, AND IT FAILS IN THE SAFE\n * DIRECTION BY CONSTRUCTION. Too SHORT and a late echo is treated as a stranger's change: one\n * extra read of a page that is already correct — invisible. Too LONG and a stranger's change\n * that happened to reuse our uuid would be dropped — which cannot happen, because it is a v4\n * uuid nobody else has ever seen. The only real cost of a long window is memory, so it is\n * bounded by COUNT as well as by time.\n *\n * WHAT THIS IS NOT. It is not a replacement for the store's optimistic concurrency, and it\n * never decides what is true — a dropped echo means \"I already know about this\", and every\n * path that could be wrong about that (a reconnect, a tab wake, a notice with no `op_id` at\n * all) re-reads through the door as it always did.\n */\nimport { globalSlot } from \"@ai-matrx/kit/global-slot\";\n\nimport type { Uuid } from \"./core/index\";\n\n/** How long an operation stays recognisable as ours. See the header: it fails safe both ways. */\nconst REMEMBER_MS = 30_000;\n\n/** A ceiling so a long-lived tab that writes constantly cannot grow this without bound. */\nconst REMEMBER_MAX = 500;\n\n/**\n * op id → the moment it was minted. Insertion-ordered, which is what makes the trim cheap.\n *\n * ONE LEDGER PER REALM, IN A GLOBAL SLOT. Every section of the built package (root, core, react,\n * realtime, data, typed-table) carries its own copy of this module, and the ESM and CJS builds are\n * two more graphs; a module-level Map split the ledger so `records/core` minted in one and\n * `records/realtime` checked another — own writes came back as strangers' changes (2026-10-08).\n * Guarded on the packed tarball by scripts/check-shared-state.mjs.\n */\nconst ours = globalSlot(\"ai-matrx.records.op-ledger\", () => new Map<string, number>());\n\nfunction trim(now: number): void {\n // A Map iterates in insertion order and ids are minted in time order, so the first entry\n // that is still inside the window means every entry after it is too.\n for (const [id, at] of ours) {\n if (now - at <= REMEMBER_MS) break;\n ours.delete(id);\n }\n // Then the ceiling, oldest first, for a tab that writes faster than the window expires.\n while (ours.size > REMEMBER_MAX) {\n const oldest = ours.keys().next();\n if (oldest.done === true) break;\n ours.delete(oldest.value);\n }\n}\n\n/**\n * Mint the id for one client operation and remember it as ours.\n *\n * ONE CALL IS ONE OPERATION. A fifty-row paste through `recordWriteMany` is ONE op id, because\n * the store announces one statement once — and it REFUSES a batch carrying two different ones\n * by name, for exactly that reason.\n */\nexport function mintOpId(): Uuid {\n const id = crypto.randomUUID();\n const now = Date.now();\n ours.set(id, now);\n trim(now);\n return id as Uuid;\n}\n\n/**\n * Is this notice the echo of a write this browser made?\n *\n * `undefined`/`null` is NOT ours and never has been: a notice with no `op_id` came from a\n * server-side write, an agent tool, another client, or a store older than this mechanism —\n * all of which are changes this browser has not seen and must read.\n */\nexport function isOwnOp(opId: string | null | undefined): boolean {\n if (!opId) return false;\n const at = ours.get(opId);\n if (at === undefined) return false;\n if (Date.now() - at > REMEMBER_MS) {\n ours.delete(opId);\n return false;\n }\n return true;\n}\n\n/** Test seam: forget everything. Never called by product code. */\nexport function forgetAllOps(): void {\n ours.clear();\n}\n\n/** Test seam: how many operations are currently remembered. */\nexport function rememberedOpCount(): number {\n return ours.size;\n}\n"],"mappings":"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","names":[]}
|
package/dist/realtime/index.js
CHANGED
|
@@ -5,8 +5,9 @@ import {
|
|
|
5
5
|
} from "@ai-matrx/realtime";
|
|
6
6
|
|
|
7
7
|
// src/ops.ts
|
|
8
|
+
import { globalSlot } from "@ai-matrx/kit/global-slot";
|
|
8
9
|
var REMEMBER_MS = 3e4;
|
|
9
|
-
var ours = /* @__PURE__ */ new Map();
|
|
10
|
+
var ours = globalSlot("ai-matrx.records.op-ledger", () => /* @__PURE__ */ new Map());
|
|
10
11
|
function isOwnOp(opId) {
|
|
11
12
|
if (!opId) return false;
|
|
12
13
|
const at = ours.get(opId);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/realtime/index.ts","../../src/ops.ts"],"sourcesContent":["// src/realtime/index.ts — @ai-matrx/records/realtime\n//\n// THE STORE'S LIVE-UPDATES PORT, ONCE, FOR EVERY HOST (Applets AP-3, 2026-10-06).\n//\n// Until now `RecordsRealtimePort` was a contract with no implementation in the package: matrx-frontend\n// wrote its own (features/unified-data/realtime/recordsRealtimePort.ts) and every other host — the\n// Applets platform host first — would have had to write a second, or go without and never fire a\n// `rows:<alias>` event. This is that implementation, moved here so every host binds the same one.\n//\n// HOW THE STORE GOES LIVE. Schema `custom` is doors-only (zero table grants) and `custom.record` is\n// hash-partitioned, so `postgres_changes` cannot serve it. The database BROADCASTS a notice instead,\n// on a PRIVATE topic per Table — `custom:table:<table_id>`, event `records.changed`, written by\n// `custom.io_outbox_broadcast_stmt` and admitted by the one RLS policy on `realtime.messages`, which\n// asks the store's own ladder (`custom.assert_may_know_table`). This file joins that topic through\n// `@ai-matrx/realtime` — the ONE realtime layer (never a hand-written `.channel(`) — so the socket,\n// `setAuth` before a private join, backoff, dedupe and wake/reconnect backfill are the manager's.\n//\n// WHAT COMES DOWN THE WIRE IS NOT DATA: `{table_id, kind, op, op_id, record_ids, fields_changed, at}`\n// — ids, never values. The hooks re-read the named ids through the read door, which applies the ladder.\n//\n// · ECHO SUPPRESSION BY OP ID — every write through this package mints `_op_id`; the store returns it\n// as the notice's `op_id`; `isOwnOp` drops our own write's echo before anything else.\n// · ONE CHANNEL PER TABLE, HOWEVER MANY READERS — opened by the first listener, closed by the last;\n// each listener coalesces its own bursts.\n// · A COLUMN IS NOT A ROW — a `field` / `table` notice calls `sink.shape()`.\n// · NO REPLAY ON A WAKE OR RECONNECT — the only correct answer is `sink.records(null)` (read again).\n\nimport {\n defineChannelNamespace,\n subscribeToRealtimeManager,\n type ChannelSpec,\n type RealtimeManager,\n} from \"@ai-matrx/realtime\";\n\nimport { isOwnOp } from \"../ops\";\nimport type { RecordsChangeSink, RecordsRealtimePort } from \"../ports\";\nimport type { Uuid } from \"../record\";\n\n/**\n * The topic is a CONTRACT WITH THE DATABASE (a Postgres trigger writes it, an RLS policy parses it),\n * so it is built in one place through the realtime package's foreign-topic door.\n */\nexport const storeTableChannel = defineChannelNamespace({\n namespace: \"records-store-table\",\n foreignTopic: \"custom:table\",\n parts: [\"tableId\"],\n description:\n \"One Table in the record store. Carries NOTICES written by custom.io_outbox_broadcast_stmt — which record ids moved and whether the table's shape moved — never any value. Authorized by the one RLS policy on realtime.messages, which asks the store's own ladder.\",\n});\n\n/** The broadcast event the store sends on a table's topic. */\nexport const STORE_NOTICE_EVENT = \"records.changed\";\n\n/** Coalesce a burst into one re-read. A statement is already one notice; this catches two. */\nexport const NUDGE_DEBOUNCE_MS = 150;\n\nexport interface StoreNotice {\n table_id?: string;\n kind?: \"record\" | \"field\" | \"table\";\n op?: \"created\" | \"updated\" | \"deleted\";\n /** The client operation that caused it (`_op_id`), or null for a server, agent or foreign write. */\n op_id?: string | null;\n /** `null` MEANS \"re-read the page\" — above the cap the database stops listing ids. */\n record_ids?: string[] | null;\n fields_changed?: boolean;\n at?: string;\n}\n\n/** The notice's own identity: a redelivered notice is the same statement at the same instant. */\nexport function noticeKey(notice: StoreNotice): string | undefined {\n if (!notice.at) return undefined;\n const ids = notice.record_ids == null ? \"*\" : [...notice.record_ids].sort().join(\",\");\n return `${notice.table_id ?? \"\"}:${notice.kind ?? \"\"}:${notice.op ?? \"\"}:${notice.op_id ?? \"\"}:${notice.at}:${ids}`;\n}\n\ninterface TableListener {\n notice(notice: StoreNotice): void;\n backfill(): void;\n}\n\ninterface TableChannel {\n listeners: Set<TableListener>;\n stop: (() => void) | null;\n}\n\nexport interface CreateRecordsRealtimePortOptions {\n /**\n * The host's realtime manager (`@ai-matrx/realtime`). Left out, the AMBIENT manager is used — the one\n * the app mounted (`setAmbientRealtimeManager` / `<RealtimeProvider>`); the port joins when one is\n * mounted and follows it if it is replaced. Never a second socket of the port's own.\n */\n manager?: RealtimeManager;\n}\n\n/** The channel spec for one table: private, echo-guarded, deduped, backfilled. */\nexport function storeTableChannelSpec(tableId: string, onNotice: (notice: StoreNotice) => void, onBackfill: () => void): ChannelSpec {\n return {\n topic: storeTableChannel.topic({ tableId }),\n // Database Broadcast is authorized by RLS on realtime.messages — that is what `private` means here;\n // the manager awaits `setAuth()` before subscribing (its absence is a channel that looks healthy and\n // delivers nothing forever).\n private: true,\n // The sender is Postgres: no Matrx envelope. OUR OWN WRITE is known by the op id the write door carried.\n wire: { mode: \"raw\", isOwnMessage: (payload) => isOwnOp((payload as StoreNotice | null)?.op_id) },\n eventKey: (_source, payload) => noticeKey((payload ?? {}) as StoreNotice),\n broadcast: [\n {\n event: STORE_NOTICE_EVENT,\n onMessage: (message) => {\n const notice = (message.data ?? {}) as StoreNotice;\n if (isOwnOp(notice.op_id)) return; // belt and braces: the manager's test ran first\n onNotice(notice);\n },\n },\n ],\n // A CHANNEL WITH NO RECONCILIATION IS A SCREEN THAT WILL EVENTUALLY LIE: realtime has no replay.\n onBackfill: () => onBackfill(),\n };\n}\n\n/**\n * The store's live-updates port, bound to a realtime manager. Hand it to `RecordsConfig.realtime`; the\n * `@ai-matrx/records/data` port turns its sink calls into `rows:<alias>` events.\n */\nexport function createRecordsRealtimePort(options: CreateRecordsRealtimePortOptions = {}): RecordsRealtimePort {\n const channels = new Map<string, TableChannel>();\n\n const open = (tableId: string): TableChannel => {\n const channel: TableChannel = { listeners: new Set(), stop: null };\n const spec = storeTableChannelSpec(\n tableId,\n (notice) => {\n for (const listener of channel.listeners) listener.notice(notice);\n },\n () => {\n for (const listener of channel.listeners) listener.backfill();\n },\n );\n if (options.manager) {\n const handle = options.manager.open(spec);\n channel.stop = () => handle.close();\n } else {\n channel.stop = subscribeToRealtimeManager(() => spec);\n }\n return channel;\n };\n\n return {\n subscribeRecords({ table_id }, sink: RecordsChangeSink) {\n let timer: ReturnType<typeof setTimeout> | null = null;\n let pending = new Set<Uuid>();\n let wholePage = false;\n\n const flush = () => {\n timer = null;\n const ids = [...pending];\n const all = wholePage;\n pending = new Set();\n wholePage = false;\n // THE IDS ARE USED AS IDS. `null` is reserved for \"the store could not name them\" or \"the socket\n // was away\" and costs a whole read; a nudge naming three records re-reads three records.\n if (all) sink.records(null);\n else if (ids.length > 0) sink.records(ids);\n };\n const nudge = () => {\n if (timer === null) timer = setTimeout(flush, NUDGE_DEBOUNCE_MS);\n };\n\n const listener: TableListener = {\n notice(notice) {\n if (notice.kind === \"field\" || notice.kind === \"table\") sink.shape();\n if (notice.kind === \"field\") return;\n if (notice.record_ids == null) wholePage = true;\n else for (const id of notice.record_ids) pending.add(id);\n nudge();\n },\n backfill() {\n wholePage = true;\n flush();\n },\n };\n\n const channel = channels.get(table_id) ?? open(table_id);\n channels.set(table_id, channel);\n channel.listeners.add(listener);\n\n return () => {\n if (timer !== null) clearTimeout(timer);\n channel.listeners.delete(listener);\n if (channel.listeners.size > 0) return;\n // The last reader of this table left: the table's one subscription goes with it.\n channel.stop?.();\n channels.delete(table_id);\n };\n },\n };\n}\n","/**\n * THE CLIENT OPERATION LEDGER — how a browser recognises the echo of its own write.\n *\n * Every private-channel notice the record store broadcasts reaches EVERY browser watching that\n * Table, including the one that caused it. Until now the writer had no way to tell its own\n * change from a colleague's: the notice carried ids and nothing about who moved them, so\n * `recordsRealtimePort.ts` declared `acceptEchoFromSelf: true` and the writer re-read a page it\n * had already updated. It was named honestly rather than faked, and this is the other half.\n *\n * THE SHAPE. Every write through `RecordsClient` mints a uuid, sends it to the store as the\n * platform envelope key `_op_id`, and remembers it here. The store lifts that key out before\n * storage — it is never written onto the record — carries it on the change-feed row, and puts\n * it in the notice as `op_id`. A notice whose `op_id` is in this ledger is this browser's own\n * echo and is dropped.\n *\n * WHY A TIME WINDOW RATHER THAN A DELETE-ON-MATCH. One write can produce MORE THAN ONE notice:\n * a record write that also moves a column produces a `record` notice and a `field` notice, and\n * a statement touching two Tables produces one per Table. Forgetting an op the first time it\n * came back would make the second notice look like somebody else's and cost the re-read this\n * exists to avoid. So an op is remembered for a window and expires on its own.\n *\n * 🚨 THE WINDOW IS A CORRECTNESS BOUNDARY, NOT A TUNING KNOB, AND IT FAILS IN THE SAFE\n * DIRECTION BY CONSTRUCTION. Too SHORT and a late echo is treated as a stranger's change: one\n * extra read of a page that is already correct — invisible. Too LONG and a stranger's change\n * that happened to reuse our uuid would be dropped — which cannot happen, because it is a v4\n * uuid nobody else has ever seen. The only real cost of a long window is memory, so it is\n * bounded by COUNT as well as by time.\n *\n * WHAT THIS IS NOT. It is not a replacement for the store's optimistic concurrency, and it\n * never decides what is true — a dropped echo means \"I already know about this\", and every\n * path that could be wrong about that (a reconnect, a tab wake, a notice with no `op_id` at\n * all) re-reads through the door as it always did.\n */\nimport type { Uuid } from \"./core/index\";\n\n/** How long an operation stays recognisable as ours. See the header: it fails safe both ways. */\nconst REMEMBER_MS = 30_000;\n\n/** A ceiling so a long-lived tab that writes constantly cannot grow this without bound. */\nconst REMEMBER_MAX = 500;\n\n/** op id → the moment it was minted. Insertion-ordered, which is what makes the trim cheap. */\nconst ours = new Map<string, number>();\n\nfunction trim(now: number): void {\n // A Map iterates in insertion order and ids are minted in time order, so the first entry\n // that is still inside the window means every entry after it is too.\n for (const [id, at] of ours) {\n if (now - at <= REMEMBER_MS) break;\n ours.delete(id);\n }\n // Then the ceiling, oldest first, for a tab that writes faster than the window expires.\n while (ours.size > REMEMBER_MAX) {\n const oldest = ours.keys().next();\n if (oldest.done === true) break;\n ours.delete(oldest.value);\n }\n}\n\n/**\n * Mint the id for one client operation and remember it as ours.\n *\n * ONE CALL IS ONE OPERATION. A fifty-row paste through `recordWriteMany` is ONE op id, because\n * the store announces one statement once — and it REFUSES a batch carrying two different ones\n * by name, for exactly that reason.\n */\nexport function mintOpId(): Uuid {\n const id = crypto.randomUUID();\n const now = Date.now();\n ours.set(id, now);\n trim(now);\n return id as Uuid;\n}\n\n/**\n * Is this notice the echo of a write this browser made?\n *\n * `undefined`/`null` is NOT ours and never has been: a notice with no `op_id` came from a\n * server-side write, an agent tool, another client, or a store older than this mechanism —\n * all of which are changes this browser has not seen and must read.\n */\nexport function isOwnOp(opId: string | null | undefined): boolean {\n if (!opId) return false;\n const at = ours.get(opId);\n if (at === undefined) return false;\n if (Date.now() - at > REMEMBER_MS) {\n ours.delete(opId);\n return false;\n }\n return true;\n}\n\n/** Test seam: forget everything. Never called by product code. */\nexport function forgetAllOps(): void {\n ours.clear();\n}\n\n/** Test seam: how many operations are currently remembered. */\nexport function rememberedOpCount(): number {\n return ours.size;\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/realtime/index.ts","../../src/ops.ts"],"sourcesContent":["// src/realtime/index.ts — @ai-matrx/records/realtime\n//\n// THE STORE'S LIVE-UPDATES PORT, ONCE, FOR EVERY HOST (Applets AP-3, 2026-10-06).\n//\n// Until now `RecordsRealtimePort` was a contract with no implementation in the package: matrx-frontend\n// wrote its own (features/unified-data/realtime/recordsRealtimePort.ts) and every other host — the\n// Applets platform host first — would have had to write a second, or go without and never fire a\n// `rows:<alias>` event. This is that implementation, moved here so every host binds the same one.\n//\n// HOW THE STORE GOES LIVE. Schema `custom` is doors-only (zero table grants) and `custom.record` is\n// hash-partitioned, so `postgres_changes` cannot serve it. The database BROADCASTS a notice instead,\n// on a PRIVATE topic per Table — `custom:table:<table_id>`, event `records.changed`, written by\n// `custom.io_outbox_broadcast_stmt` and admitted by the one RLS policy on `realtime.messages`, which\n// asks the store's own ladder (`custom.assert_may_know_table`). This file joins that topic through\n// `@ai-matrx/realtime` — the ONE realtime layer (never a hand-written `.channel(`) — so the socket,\n// `setAuth` before a private join, backoff, dedupe and wake/reconnect backfill are the manager's.\n//\n// WHAT COMES DOWN THE WIRE IS NOT DATA: `{table_id, kind, op, op_id, record_ids, fields_changed, at}`\n// — ids, never values. The hooks re-read the named ids through the read door, which applies the ladder.\n//\n// · ECHO SUPPRESSION BY OP ID — every write through this package mints `_op_id`; the store returns it\n// as the notice's `op_id`; `isOwnOp` drops our own write's echo before anything else.\n// · ONE CHANNEL PER TABLE, HOWEVER MANY READERS — opened by the first listener, closed by the last;\n// each listener coalesces its own bursts.\n// · A COLUMN IS NOT A ROW — a `field` / `table` notice calls `sink.shape()`.\n// · NO REPLAY ON A WAKE OR RECONNECT — the only correct answer is `sink.records(null)` (read again).\n\nimport {\n defineChannelNamespace,\n subscribeToRealtimeManager,\n type ChannelSpec,\n type RealtimeManager,\n} from \"@ai-matrx/realtime\";\n\nimport { isOwnOp } from \"../ops\";\nimport type { RecordsChangeSink, RecordsRealtimePort } from \"../ports\";\nimport type { Uuid } from \"../record\";\n\n/**\n * The topic is a CONTRACT WITH THE DATABASE (a Postgres trigger writes it, an RLS policy parses it),\n * so it is built in one place through the realtime package's foreign-topic door.\n */\nexport const storeTableChannel = defineChannelNamespace({\n namespace: \"records-store-table\",\n foreignTopic: \"custom:table\",\n parts: [\"tableId\"],\n description:\n \"One Table in the record store. Carries NOTICES written by custom.io_outbox_broadcast_stmt — which record ids moved and whether the table's shape moved — never any value. Authorized by the one RLS policy on realtime.messages, which asks the store's own ladder.\",\n});\n\n/** The broadcast event the store sends on a table's topic. */\nexport const STORE_NOTICE_EVENT = \"records.changed\";\n\n/** Coalesce a burst into one re-read. A statement is already one notice; this catches two. */\nexport const NUDGE_DEBOUNCE_MS = 150;\n\nexport interface StoreNotice {\n table_id?: string;\n kind?: \"record\" | \"field\" | \"table\";\n op?: \"created\" | \"updated\" | \"deleted\";\n /** The client operation that caused it (`_op_id`), or null for a server, agent or foreign write. */\n op_id?: string | null;\n /** `null` MEANS \"re-read the page\" — above the cap the database stops listing ids. */\n record_ids?: string[] | null;\n fields_changed?: boolean;\n at?: string;\n}\n\n/** The notice's own identity: a redelivered notice is the same statement at the same instant. */\nexport function noticeKey(notice: StoreNotice): string | undefined {\n if (!notice.at) return undefined;\n const ids = notice.record_ids == null ? \"*\" : [...notice.record_ids].sort().join(\",\");\n return `${notice.table_id ?? \"\"}:${notice.kind ?? \"\"}:${notice.op ?? \"\"}:${notice.op_id ?? \"\"}:${notice.at}:${ids}`;\n}\n\ninterface TableListener {\n notice(notice: StoreNotice): void;\n backfill(): void;\n}\n\ninterface TableChannel {\n listeners: Set<TableListener>;\n stop: (() => void) | null;\n}\n\nexport interface CreateRecordsRealtimePortOptions {\n /**\n * The host's realtime manager (`@ai-matrx/realtime`). Left out, the AMBIENT manager is used — the one\n * the app mounted (`setAmbientRealtimeManager` / `<RealtimeProvider>`); the port joins when one is\n * mounted and follows it if it is replaced. Never a second socket of the port's own.\n */\n manager?: RealtimeManager;\n}\n\n/** The channel spec for one table: private, echo-guarded, deduped, backfilled. */\nexport function storeTableChannelSpec(tableId: string, onNotice: (notice: StoreNotice) => void, onBackfill: () => void): ChannelSpec {\n return {\n topic: storeTableChannel.topic({ tableId }),\n // Database Broadcast is authorized by RLS on realtime.messages — that is what `private` means here;\n // the manager awaits `setAuth()` before subscribing (its absence is a channel that looks healthy and\n // delivers nothing forever).\n private: true,\n // The sender is Postgres: no Matrx envelope. OUR OWN WRITE is known by the op id the write door carried.\n wire: { mode: \"raw\", isOwnMessage: (payload) => isOwnOp((payload as StoreNotice | null)?.op_id) },\n eventKey: (_source, payload) => noticeKey((payload ?? {}) as StoreNotice),\n broadcast: [\n {\n event: STORE_NOTICE_EVENT,\n onMessage: (message) => {\n const notice = (message.data ?? {}) as StoreNotice;\n if (isOwnOp(notice.op_id)) return; // belt and braces: the manager's test ran first\n onNotice(notice);\n },\n },\n ],\n // A CHANNEL WITH NO RECONCILIATION IS A SCREEN THAT WILL EVENTUALLY LIE: realtime has no replay.\n onBackfill: () => onBackfill(),\n };\n}\n\n/**\n * The store's live-updates port, bound to a realtime manager. Hand it to `RecordsConfig.realtime`; the\n * `@ai-matrx/records/data` port turns its sink calls into `rows:<alias>` events.\n */\nexport function createRecordsRealtimePort(options: CreateRecordsRealtimePortOptions = {}): RecordsRealtimePort {\n const channels = new Map<string, TableChannel>();\n\n const open = (tableId: string): TableChannel => {\n const channel: TableChannel = { listeners: new Set(), stop: null };\n const spec = storeTableChannelSpec(\n tableId,\n (notice) => {\n for (const listener of channel.listeners) listener.notice(notice);\n },\n () => {\n for (const listener of channel.listeners) listener.backfill();\n },\n );\n if (options.manager) {\n const handle = options.manager.open(spec);\n channel.stop = () => handle.close();\n } else {\n channel.stop = subscribeToRealtimeManager(() => spec);\n }\n return channel;\n };\n\n return {\n subscribeRecords({ table_id }, sink: RecordsChangeSink) {\n let timer: ReturnType<typeof setTimeout> | null = null;\n let pending = new Set<Uuid>();\n let wholePage = false;\n\n const flush = () => {\n timer = null;\n const ids = [...pending];\n const all = wholePage;\n pending = new Set();\n wholePage = false;\n // THE IDS ARE USED AS IDS. `null` is reserved for \"the store could not name them\" or \"the socket\n // was away\" and costs a whole read; a nudge naming three records re-reads three records.\n if (all) sink.records(null);\n else if (ids.length > 0) sink.records(ids);\n };\n const nudge = () => {\n if (timer === null) timer = setTimeout(flush, NUDGE_DEBOUNCE_MS);\n };\n\n const listener: TableListener = {\n notice(notice) {\n if (notice.kind === \"field\" || notice.kind === \"table\") sink.shape();\n if (notice.kind === \"field\") return;\n if (notice.record_ids == null) wholePage = true;\n else for (const id of notice.record_ids) pending.add(id);\n nudge();\n },\n backfill() {\n wholePage = true;\n flush();\n },\n };\n\n const channel = channels.get(table_id) ?? open(table_id);\n channels.set(table_id, channel);\n channel.listeners.add(listener);\n\n return () => {\n if (timer !== null) clearTimeout(timer);\n channel.listeners.delete(listener);\n if (channel.listeners.size > 0) return;\n // The last reader of this table left: the table's one subscription goes with it.\n channel.stop?.();\n channels.delete(table_id);\n };\n },\n };\n}\n","/**\n * THE CLIENT OPERATION LEDGER — how a browser recognises the echo of its own write.\n *\n * Every private-channel notice the record store broadcasts reaches EVERY browser watching that\n * Table, including the one that caused it. Until now the writer had no way to tell its own\n * change from a colleague's: the notice carried ids and nothing about who moved them, so\n * `recordsRealtimePort.ts` declared `acceptEchoFromSelf: true` and the writer re-read a page it\n * had already updated. It was named honestly rather than faked, and this is the other half.\n *\n * THE SHAPE. Every write through `RecordsClient` mints a uuid, sends it to the store as the\n * platform envelope key `_op_id`, and remembers it here. The store lifts that key out before\n * storage — it is never written onto the record — carries it on the change-feed row, and puts\n * it in the notice as `op_id`. A notice whose `op_id` is in this ledger is this browser's own\n * echo and is dropped.\n *\n * WHY A TIME WINDOW RATHER THAN A DELETE-ON-MATCH. One write can produce MORE THAN ONE notice:\n * a record write that also moves a column produces a `record` notice and a `field` notice, and\n * a statement touching two Tables produces one per Table. Forgetting an op the first time it\n * came back would make the second notice look like somebody else's and cost the re-read this\n * exists to avoid. So an op is remembered for a window and expires on its own.\n *\n * 🚨 THE WINDOW IS A CORRECTNESS BOUNDARY, NOT A TUNING KNOB, AND IT FAILS IN THE SAFE\n * DIRECTION BY CONSTRUCTION. Too SHORT and a late echo is treated as a stranger's change: one\n * extra read of a page that is already correct — invisible. Too LONG and a stranger's change\n * that happened to reuse our uuid would be dropped — which cannot happen, because it is a v4\n * uuid nobody else has ever seen. The only real cost of a long window is memory, so it is\n * bounded by COUNT as well as by time.\n *\n * WHAT THIS IS NOT. It is not a replacement for the store's optimistic concurrency, and it\n * never decides what is true — a dropped echo means \"I already know about this\", and every\n * path that could be wrong about that (a reconnect, a tab wake, a notice with no `op_id` at\n * all) re-reads through the door as it always did.\n */\nimport { globalSlot } from \"@ai-matrx/kit/global-slot\";\n\nimport type { Uuid } from \"./core/index\";\n\n/** How long an operation stays recognisable as ours. See the header: it fails safe both ways. */\nconst REMEMBER_MS = 30_000;\n\n/** A ceiling so a long-lived tab that writes constantly cannot grow this without bound. */\nconst REMEMBER_MAX = 500;\n\n/**\n * op id → the moment it was minted. Insertion-ordered, which is what makes the trim cheap.\n *\n * ONE LEDGER PER REALM, IN A GLOBAL SLOT. Every section of the built package (root, core, react,\n * realtime, data, typed-table) carries its own copy of this module, and the ESM and CJS builds are\n * two more graphs; a module-level Map split the ledger so `records/core` minted in one and\n * `records/realtime` checked another — own writes came back as strangers' changes (2026-10-08).\n * Guarded on the packed tarball by scripts/check-shared-state.mjs.\n */\nconst ours = globalSlot(\"ai-matrx.records.op-ledger\", () => new Map<string, number>());\n\nfunction trim(now: number): void {\n // A Map iterates in insertion order and ids are minted in time order, so the first entry\n // that is still inside the window means every entry after it is too.\n for (const [id, at] of ours) {\n if (now - at <= REMEMBER_MS) break;\n ours.delete(id);\n }\n // Then the ceiling, oldest first, for a tab that writes faster than the window expires.\n while (ours.size > REMEMBER_MAX) {\n const oldest = ours.keys().next();\n if (oldest.done === true) break;\n ours.delete(oldest.value);\n }\n}\n\n/**\n * Mint the id for one client operation and remember it as ours.\n *\n * ONE CALL IS ONE OPERATION. A fifty-row paste through `recordWriteMany` is ONE op id, because\n * the store announces one statement once — and it REFUSES a batch carrying two different ones\n * by name, for exactly that reason.\n */\nexport function mintOpId(): Uuid {\n const id = crypto.randomUUID();\n const now = Date.now();\n ours.set(id, now);\n trim(now);\n return id as Uuid;\n}\n\n/**\n * Is this notice the echo of a write this browser made?\n *\n * `undefined`/`null` is NOT ours and never has been: a notice with no `op_id` came from a\n * server-side write, an agent tool, another client, or a store older than this mechanism —\n * all of which are changes this browser has not seen and must read.\n */\nexport function isOwnOp(opId: string | null | undefined): boolean {\n if (!opId) return false;\n const at = ours.get(opId);\n if (at === undefined) return false;\n if (Date.now() - at > REMEMBER_MS) {\n ours.delete(opId);\n return false;\n }\n return true;\n}\n\n/** Test seam: forget everything. Never called by product code. */\nexport function forgetAllOps(): void {\n ours.clear();\n}\n\n/** Test seam: how many operations are currently remembered. */\nexport function rememberedOpCount(): number {\n return ours.size;\n}\n"],"mappings":"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","names":[]}
|
|
@@ -212,7 +212,7 @@ var f = {
|
|
|
212
212
|
member: (opts) => field("member", opts),
|
|
213
213
|
/** A file. */
|
|
214
214
|
attachment: (opts) => field("attachment", opts),
|
|
215
|
-
/** A platform thing (`note`, `agent` …) of one of `kinds
|
|
215
|
+
/** A platform thing (`note`, `agent` …) of one of `kinds`: `{ token, id }`, the store's own shape. */
|
|
216
216
|
entityReference: (kinds, opts) => field("entity_reference", opts, { allowedTypes: [...kinds] }),
|
|
217
217
|
/** Computed by the store from `formulaText` (`{Visit fee} - {Deposit taken}`); read-only. */
|
|
218
218
|
formula: (formulaText, opts) => field("formula", opts, { declaration: { formula_text: formulaText, ...opts?.declaration ?? {} } }, true),
|
|
@@ -492,7 +492,8 @@ function isWritingDoor(door) {
|
|
|
492
492
|
}
|
|
493
493
|
|
|
494
494
|
// src/core/recordChanges.ts
|
|
495
|
-
var
|
|
495
|
+
var import_global_slot = require("@ai-matrx/kit/global-slot");
|
|
496
|
+
var listeners = (0, import_global_slot.globalSlot)("ai-matrx.records.record-change-listeners", () => /* @__PURE__ */ new Set());
|
|
496
497
|
function announceRecordChanged(recordId) {
|
|
497
498
|
for (const listener of [...listeners]) {
|
|
498
499
|
try {
|
|
@@ -502,7 +503,7 @@ function announceRecordChanged(recordId) {
|
|
|
502
503
|
}
|
|
503
504
|
}
|
|
504
505
|
}
|
|
505
|
-
var removed = /* @__PURE__ */ new Set();
|
|
506
|
+
var removed = (0, import_global_slot.globalSlot)("ai-matrx.records.removed-records", () => /* @__PURE__ */ new Set());
|
|
506
507
|
function markRecordRemoved(recordId) {
|
|
507
508
|
removed.add(recordId);
|
|
508
509
|
}
|
|
@@ -513,7 +514,7 @@ function onRecordChanged(listener) {
|
|
|
513
514
|
listeners.add(listener);
|
|
514
515
|
return () => listeners.delete(listener);
|
|
515
516
|
}
|
|
516
|
-
var tableListeners = /* @__PURE__ */ new Set();
|
|
517
|
+
var tableListeners = (0, import_global_slot.globalSlot)("ai-matrx.records.table-structure-listeners", () => /* @__PURE__ */ new Set());
|
|
517
518
|
function announceTableStructureChanged(tableId) {
|
|
518
519
|
for (const listener of [...tableListeners]) {
|
|
519
520
|
try {
|
|
@@ -803,9 +804,10 @@ function sourceArchivedOf(value) {
|
|
|
803
804
|
}
|
|
804
805
|
|
|
805
806
|
// src/ops.ts
|
|
807
|
+
var import_global_slot2 = require("@ai-matrx/kit/global-slot");
|
|
806
808
|
var REMEMBER_MS = 3e4;
|
|
807
809
|
var REMEMBER_MAX = 500;
|
|
808
|
-
var ours = /* @__PURE__ */ new Map();
|
|
810
|
+
var ours = (0, import_global_slot2.globalSlot)("ai-matrx.records.op-ledger", () => /* @__PURE__ */ new Map());
|
|
809
811
|
function trim(now) {
|
|
810
812
|
for (const [id, at] of ours) {
|
|
811
813
|
if (now - at <= REMEMBER_MS) break;
|