@ai-matrx/media 0.7.16 → 0.9.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/CHANGELOG.md +25 -0
- package/dist/files/engine/api/assets.d.ts +223 -0
- package/dist/files/engine/api/assets.js +124 -0
- package/dist/files/engine/api/assets.js.map +1 -0
- package/dist/files/engine/api/direct.d.ts +43 -0
- package/dist/files/engine/api/direct.js +73 -0
- package/dist/files/engine/api/direct.js.map +1 -0
- package/dist/files/engine/api/fileOrganization.d.ts +34 -0
- package/dist/files/engine/api/fileOrganization.js +38 -0
- package/dist/files/engine/api/fileOrganization.js.map +1 -0
- package/dist/files/engine/api/files.d.ts +249 -0
- package/dist/files/engine/api/files.js +231 -0
- package/dist/files/engine/api/files.js.map +1 -0
- package/dist/files/engine/api/folders.d.ts +63 -0
- package/dist/files/engine/api/folders.js +56 -0
- package/dist/files/engine/api/folders.js.map +1 -0
- package/dist/files/engine/api/office.d.ts +27 -0
- package/dist/files/engine/api/office.js +19 -0
- package/dist/files/engine/api/office.js.map +1 -0
- package/dist/files/engine/api/permissions.d.ts +37 -0
- package/dist/files/engine/api/permissions.js +109 -0
- package/dist/files/engine/api/permissions.js.map +1 -0
- package/dist/files/engine/api/versions.d.ts +26 -0
- package/dist/files/engine/api/versions.js +37 -0
- package/dist/files/engine/api/versions.js.map +1 -0
- package/dist/files/engine/cache/idb-store.d.ts +95 -0
- package/dist/files/engine/cache/idb-store.js +172 -0
- package/dist/files/engine/cache/idb-store.js.map +1 -0
- package/dist/files/engine/cache/policy.d.ts +14 -0
- package/dist/files/engine/cache/policy.js +40 -0
- package/dist/files/engine/cache/policy.js.map +1 -0
- package/dist/files/engine/cache/register-service-worker.d.ts +57 -0
- package/dist/files/engine/cache/register-service-worker.js +80 -0
- package/dist/files/engine/cache/register-service-worker.js.map +1 -0
- package/dist/files/engine/db-types.d.ts +48865 -0
- package/dist/files/engine/db-types.js +1 -0
- package/dist/files/engine/db-types.js.map +1 -0
- package/dist/files/engine/filesDb.d.ts +1913 -0
- package/dist/files/engine/filesDb.js +28 -0
- package/dist/files/engine/filesDb.js.map +1 -0
- package/dist/files/engine/handler/handler.d.ts +82 -0
- package/dist/files/engine/handler/handler.js +124 -0
- package/dist/files/engine/handler/handler.js.map +1 -0
- package/dist/files/engine/handler/hooks/useFile.d.ts +17 -0
- package/dist/files/engine/handler/hooks/useFile.js +75 -0
- package/dist/files/engine/handler/hooks/useFile.js.map +1 -0
- package/dist/files/engine/handler/hooks/useFileUpload.d.ts +76 -0
- package/dist/files/engine/handler/hooks/useFileUpload.js +67 -0
- package/dist/files/engine/handler/hooks/useFileUpload.js.map +1 -0
- package/dist/files/engine/handler/input/normalize.d.ts +14 -0
- package/dist/files/engine/handler/input/normalize.js +365 -0
- package/dist/files/engine/handler/input/normalize.js.map +1 -0
- package/dist/files/engine/handler/intelligence/access.d.ts +35 -0
- package/dist/files/engine/handler/intelligence/access.js +85 -0
- package/dist/files/engine/handler/intelligence/access.js.map +1 -0
- package/dist/files/engine/handler/intelligence/magic-bytes.d.ts +21 -0
- package/dist/files/engine/handler/intelligence/magic-bytes.js +67 -0
- package/dist/files/engine/handler/intelligence/magic-bytes.js.map +1 -0
- package/dist/files/engine/handler/output/target.d.ts +19 -0
- package/dist/files/engine/handler/output/target.js +222 -0
- package/dist/files/engine/handler/output/target.js.map +1 -0
- package/dist/files/engine/handler/resolver.d.ts +34 -0
- package/dist/files/engine/handler/resolver.js +133 -0
- package/dist/files/engine/handler/resolver.js.map +1 -0
- package/dist/files/engine/handler/types.d.ts +367 -0
- package/dist/files/engine/handler/types.js +1 -0
- package/dist/files/engine/handler/types.js.map +1 -0
- package/dist/files/engine/handler/upload.d.ts +32 -0
- package/dist/files/engine/handler/upload.js +295 -0
- package/dist/files/engine/handler/upload.js.map +1 -0
- package/dist/files/engine/handler/utils/classify.d.ts +14 -0
- package/dist/files/engine/handler/utils/classify.js +27 -0
- package/dist/files/engine/handler/utils/classify.js.map +1 -0
- package/dist/files/engine/handler/utils/prefer-locator.d.ts +32 -0
- package/dist/files/engine/handler/utils/prefer-locator.js +34 -0
- package/dist/files/engine/handler/utils/prefer-locator.js.map +1 -0
- package/dist/files/engine/handler/utils/python-base.d.ts +129 -0
- package/dist/files/engine/handler/utils/python-base.js +60 -0
- package/dist/files/engine/handler/utils/python-base.js.map +1 -0
- package/dist/files/engine/hooks/blob-cache.d.ts +130 -0
- package/dist/files/engine/hooks/blob-cache.js +162 -0
- package/dist/files/engine/hooks/blob-cache.js.map +1 -0
- package/dist/files/engine/hooks/office-extraction-cache.d.ts +28 -0
- package/dist/files/engine/hooks/office-extraction-cache.js +67 -0
- package/dist/files/engine/hooks/office-extraction-cache.js.map +1 -0
- package/dist/files/engine/host/configure.d.ts +166 -0
- package/dist/files/engine/host/configure.js +30 -0
- package/dist/files/engine/host/configure.js.map +1 -0
- package/dist/files/engine/host/org.d.ts +8 -0
- package/dist/files/engine/host/org.js +16 -0
- package/dist/files/engine/host/org.js.map +1 -0
- package/dist/files/engine/host/python-client.d.ts +60 -0
- package/dist/files/engine/host/python-client.js +46 -0
- package/dist/files/engine/host/python-client.js.map +1 -0
- package/dist/files/engine/host/share-links.d.ts +20 -0
- package/dist/files/engine/host/share-links.js +32 -0
- package/dist/files/engine/host/share-links.js.map +1 -0
- package/dist/files/engine/host/store.d.ts +24 -0
- package/dist/files/engine/host/store.js +23 -0
- package/dist/files/engine/host/store.js.map +1 -0
- package/dist/files/engine/host/supabase.d.ts +3808 -0
- package/dist/files/engine/host/supabase.js +33 -0
- package/dist/files/engine/host/supabase.js.map +1 -0
- package/dist/files/engine/host/toast.d.ts +14 -0
- package/dist/files/engine/host/toast.js +20 -0
- package/dist/files/engine/host/toast.js.map +1 -0
- package/dist/files/engine/host/typed-client.d.ts +136 -0
- package/dist/files/engine/host/typed-client.js +66 -0
- package/dist/files/engine/host/typed-client.js.map +1 -0
- package/dist/files/engine/index.d.ts +22 -0
- package/dist/files/engine/index.js +49 -0
- package/dist/files/engine/index.js.map +1 -0
- package/dist/files/engine/media/our-file-sources.d.ts +39 -0
- package/dist/files/engine/media/our-file-sources.js +83 -0
- package/dist/files/engine/media/our-file-sources.js.map +1 -0
- package/dist/files/engine/media/signed-url.d.ts +1 -0
- package/dist/files/engine/media/signed-url.js +6 -0
- package/dist/files/engine/media/signed-url.js.map +1 -0
- package/dist/files/engine/redux/converters.d.ts +73 -0
- package/dist/files/engine/redux/converters.js +286 -0
- package/dist/files/engine/redux/converters.js.map +1 -0
- package/dist/files/engine/redux/file-hydration.d.ts +15 -0
- package/dist/files/engine/redux/file-hydration.js +46 -0
- package/dist/files/engine/redux/file-hydration.js.map +1 -0
- package/dist/files/engine/redux/file-tree-auth-boundary.d.ts +5 -0
- package/dist/files/engine/redux/file-tree-auth-boundary.js +28 -0
- package/dist/files/engine/redux/file-tree-auth-boundary.js.map +1 -0
- package/dist/files/engine/redux/file-tree-timeout.d.ts +32 -0
- package/dist/files/engine/redux/file-tree-timeout.js +73 -0
- package/dist/files/engine/redux/file-tree-timeout.js.map +1 -0
- package/dist/files/engine/redux/mutation-toast-middleware.d.ts +27 -0
- package/dist/files/engine/redux/mutation-toast-middleware.js +103 -0
- package/dist/files/engine/redux/mutation-toast-middleware.js.map +1 -0
- package/dist/files/engine/redux/realtime-middleware.d.ts +61 -0
- package/dist/files/engine/redux/realtime-middleware.js +431 -0
- package/dist/files/engine/redux/realtime-middleware.js.map +1 -0
- package/dist/files/engine/redux/request-ledger.d.ts +68 -0
- package/dist/files/engine/redux/request-ledger.js +66 -0
- package/dist/files/engine/redux/request-ledger.js.map +1 -0
- package/dist/files/engine/redux/selectors.d.ts +3254 -0
- package/dist/files/engine/redux/selectors.js +428 -0
- package/dist/files/engine/redux/selectors.js.map +1 -0
- package/dist/files/engine/redux/slice.d.ts +157 -0
- package/dist/files/engine/redux/slice.js +836 -0
- package/dist/files/engine/redux/slice.js.map +1 -0
- package/dist/files/engine/redux/thunks.d.ts +449 -0
- package/dist/files/engine/redux/thunks.js +1684 -0
- package/dist/files/engine/redux/thunks.js.map +1 -0
- package/dist/files/engine/redux/tree-utils.d.ts +90 -0
- package/dist/files/engine/redux/tree-utils.js +200 -0
- package/dist/files/engine/redux/tree-utils.js.map +1 -0
- package/dist/files/engine/support/claimsUser.d.ts +77 -0
- package/dist/files/engine/support/claimsUser.js +48 -0
- package/dist/files/engine/support/claimsUser.js.map +1 -0
- package/dist/files/engine/support/datetime.d.ts +6 -0
- package/dist/files/engine/support/datetime.js +15 -0
- package/dist/files/engine/support/datetime.js.map +1 -0
- package/dist/files/engine/support/document-visibility.d.ts +14 -0
- package/dist/files/engine/support/document-visibility.js +28 -0
- package/dist/files/engine/support/document-visibility.js.map +1 -0
- package/dist/files/engine/support/logger.d.ts +28 -0
- package/dist/files/engine/support/logger.js +36 -0
- package/dist/files/engine/support/logger.js.map +1 -0
- package/dist/files/engine/types.d.ts +1081 -0
- package/dist/files/engine/types.js +30 -0
- package/dist/files/engine/types.js.map +1 -0
- package/dist/files/engine/upload/cloudUpload.d.ts +182 -0
- package/dist/files/engine/upload/cloudUpload.js +418 -0
- package/dist/files/engine/upload/cloudUpload.js.map +1 -0
- package/dist/files/engine/upload/tusUpload.d.ts +100 -0
- package/dist/files/engine/upload/tusUpload.js +258 -0
- package/dist/files/engine/upload/tusUpload.js.map +1 -0
- package/dist/files/engine/upload/uploadDedupGuard.d.ts +58 -0
- package/dist/files/engine/upload/uploadDedupGuard.js +41 -0
- package/dist/files/engine/upload/uploadDedupGuard.js.map +1 -0
- package/dist/files/engine/upload/uploadGuardOpeners.d.ts +70 -0
- package/dist/files/engine/upload/uploadGuardOpeners.js +54 -0
- package/dist/files/engine/upload/uploadGuardOpeners.js.map +1 -0
- package/dist/files/engine/utils/file-types.d.ts +243 -0
- package/dist/files/engine/utils/file-types.js +1722 -0
- package/dist/files/engine/utils/file-types.js.map +1 -0
- package/dist/files/engine/utils/user-visible.d.ts +133 -0
- package/dist/files/engine/utils/user-visible.js +115 -0
- package/dist/files/engine/utils/user-visible.js.map +1 -0
- package/dist/files.cjs +1298 -0
- package/dist/files.cjs.map +1 -0
- package/dist/files.d.cts +1171 -0
- package/dist/files.d.ts +1171 -0
- package/dist/files.js +1275 -0
- package/dist/files.js.map +1 -0
- package/package.json +404 -9
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../src/files/engine/redux/realtime-middleware.ts"],"sourcesContent":["/**\n * features/files/redux/realtime-middleware.ts\n *\n * Bridges Supabase Realtime into Redux.\n *\n * LIFECYCLE\n * ---------\n * - Dispatch `cloudFilesRealtime/attach` (from\n * CloudFilesRealtimeProvider on mount with a valid user id). Middleware\n * opens a channel and subscribes to cld_* table changes.\n * - Dispatch `cloudFilesRealtime/detach` on logout/unmount. Middleware tears\n * the channel down.\n * - On reconnect after error, middleware re-runs the tree RPC via\n * `reconcileTree` to close any event gap.\n *\n * DEDUP\n * -----\n * Every REST write registers its `requestId` in\n * [request-ledger.ts](./request-ledger.ts). Incoming realtime payloads are\n * checked with `isOwnEcho`. Matches are skipped — the optimistic update\n * already reflects the change. Non-matches are dispatched normally, so\n * changes originating from other devices / the server / share-link\n * visitors always reach our state.\n *\n * THAT LEDGER IS NOT A SECOND COPY OF THE PACKAGE'S. `@ai-matrx/realtime`'s\n * write ledger correlates on (table, id, updated_at, content fingerprint);\n * this one correlates on `metadata.request_id`, an end-to-end write id the\n * Python backend stamps on every cloud_sync write under a documented wire\n * contract (common-docs systems/files/file-service/WIRE_CONTRACT.md). That is\n * strictly more precise than a timestamp+fingerprint inference and the package\n * has no equivalent, so it stays — as the FEATURE's correlation, on top of\n * everything the package does. (A consumer-supplied own-write predicate is a\n * fair candidate for the package; until then this is the honest home.)\n *\n * REALTIME\n * --------\n * `@ai-matrx/realtime` owns the channel: unique instance topic (this used a\n * STATIC `cloud-files:<userId>`, which React 19's double-invoked effects can\n * collide on), dedup, the decoupled ordered handler queue, jittered reconnect\n * with the stability reset, tab sleep and network awareness, diagnostics.\n *\n * The reconcile that used to hang off the SUBSCRIBED callback is now the\n * package's `onBackfill` door, which is a strictly larger set of recovery\n * paths: it also fires on tab wake, network restore and queue overflow, none\n * of which the old status callback could see. Its cooldown and in-flight\n * guards are kept — they are about not storming an expensive tree RPC, which\n * is the feature's own economics, not reconnect logic.\n */\n\n\"use client\";\n\nimport type {\n Middleware,\n ThunkDispatch,\n UnknownAction,\n} from \"@reduxjs/toolkit\";\nimport {\n defineChannelNamespace,\n subscribeToRealtimeManager,\n} from \"@ai-matrx/realtime\";\nimport type { CloudFilesState } from \"../types\";\n\n// Minimal local state type — avoids importing RootState from store.ts which\n// imports this middleware, creating a type-level cycle.\ntype StateWithCloudFiles = { cloudFiles: CloudFilesState };\n// Thunk-aware dispatch — this middleware dispatches `reconcileTree`, an\n// async thunk, so the plain `Middleware`/`Dispatch<UnknownAction>` types\n// (which don't know about thunk middleware) aren't enough. Same local\n// pattern as thunks.ts's `AppDispatch`, to avoid the store.ts import cycle.\ntype AppDispatch = ThunkDispatch<StateWithCloudFiles, unknown, UnknownAction>;\nimport type { RealtimePostgresChangesPayload } from \"@supabase/supabase-js\";\n\nimport {\n canonicalShareLinkToCloudShareLink,\n dbRowToCloudFile,\n dbRowToCloudFilePermission,\n dbRowToCloudFileVersion,\n dbRowToCloudFolder,\n} from \"./converters\";\nimport { isOwnEcho, ledgerSize } from \"./request-ledger\";\nimport { loadUserFileTree, reconcileTree } from \"./thunks\";\nimport {\n isDocumentHidden,\n onceDocumentVisible,\n} from \"../support/document-visibility\";\nimport {\n isUserVisibleFileRow,\n isUserVisibleFolderPath,\n} from \"../utils/user-visible\";\nimport {\n cachedVersion,\n invalidate as invalidateBlobCache,\n} from \"../hooks/blob-cache\";\nimport { invalidateOfficeExtraction } from \"../hooks/office-extraction-cache\";\nimport {\n attachChildToFolder,\n clearSelection,\n detachChildFromFolder,\n removeFile,\n removeFolder,\n removeShareLink,\n setRealtimeStatus,\n touchRealtime,\n upsertFile,\n upsertFolder,\n upsertPermissionsForResource,\n upsertShareLinksForResource,\n upsertVersionsForFile,\n} from \"./slice\";\nimport type {\n CloudFileRow,\n CloudFolderRow,\n CloudFilePermissionRow,\n CloudFileVersionRow,\n CloudShareLink,\n} from \"../types\";\n\n/** Raw `platform.share_links` row as delivered by postgres_changes. */\ninterface PlatformShareLinkRow {\n id?: string;\n resource_type?: string;\n resource_id?: string;\n token?: string;\n short_token?: string | null;\n permission_level?: string;\n label?: string | null;\n expires_at?: string | null;\n max_uses?: number | null;\n use_count?: number | null;\n is_active?: boolean;\n created_at?: string | null;\n last_used_at?: string | null;\n}\n\n// ---------------------------------------------------------------------------\n// Action creators — not reducers, just signals consumed by this middleware.\n// ---------------------------------------------------------------------------\n\nexport const ATTACH_ACTION = \"cloudFilesRealtime/attach\" as const;\nexport const DETACH_ACTION = \"cloudFilesRealtime/detach\" as const;\n\nexport function attachCloudFilesRealtime(userId: string) {\n return { type: ATTACH_ACTION, payload: { userId } } as const;\n}\n\nexport function detachCloudFilesRealtime() {\n return { type: DETACH_ACTION } as const;\n}\n\ntype AttachAction = ReturnType<typeof attachCloudFilesRealtime>;\ntype DetachAction = ReturnType<typeof detachCloudFilesRealtime>;\ntype RealtimeAction = AttachAction | DetachAction;\n\nfunction isAttachAction(a: unknown): a is AttachAction {\n return (\n typeof a === \"object\" &&\n a !== null &&\n (a as { type?: unknown }).type === ATTACH_ACTION\n );\n}\n\nfunction isDetachAction(a: unknown): a is DetachAction {\n return (\n typeof a === \"object\" &&\n a !== null &&\n (a as { type?: unknown }).type === DETACH_ACTION\n );\n}\n\n// ---------------------------------------------------------------------------\n// Payload helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Extract `request_id` from a realtime payload. The Python backend\n * reliably stamps `metadata.request_id` on every cloud_sync write\n * (commit `d647c143`, 2026-05-17).\n */\nfunction extractRequestId(\n payload: RealtimePostgresChangesPayload<Record<string, unknown>>,\n): string | null {\n const row = (payload.new ?? payload.old) as\n Record<string, unknown> | undefined;\n if (!row) return null;\n const meta = row.metadata as Record<string, unknown> | undefined;\n if (meta && typeof meta.request_id === \"string\") {\n return meta.request_id;\n }\n return null;\n}\n\n// ---------------------------------------------------------------------------\n// Middleware\n// ---------------------------------------------------------------------------\n\n// We intentionally leave this middleware loosely typed. Using the store's\n// RootState / AppDispatch directly creates a type-level cycle because those\n// types are derived from configureStore's middleware list — which includes\n// this middleware. Local casts inside the body give us the strong typing\n// where it actually matters.\n// P1-10: debounce reconcile-on-(re)subscribe. A flapping connection fires\n// SUBSCRIBED repeatedly; without this each one re-runs the full-tree RPC and can\n// clobber in-flight optimistic edits. We skip a reconcile if one ran within the\n// cooldown OR if any mutation is currently in flight (the ledger is non-empty).\nconst RECONCILE_COOLDOWN_MS = 10_000;\nlet lastReconcileAt = 0;\n// When the initial `loadUserFileTree` snapshot RESOLVED, or null while it is\n// still in flight. This decides whether the on-SUBSCRIBED reconcile is needed:\n// a snapshot that completed BEFORE the channel went live can have missed\n// writes committed in between (the gap the reconcile exists to close); a\n// snapshot still in flight at SUBSCRIBED will read post-subscribe state, so\n// there is no gap and the reconcile would be a pure duplicate RPC.\n// (Adversarial review 2026-08-22: the earlier seed-the-cooldown approach\n// skipped the reconcile on every normal load and silently gave up the gap\n// guarantee; this keeps both properties.)\nlet initialSnapshotDoneAt: number | null = null;\n\n/** One place names this channel. A second, different declaration throws. */\nconst cloudFilesChannel = defineChannelNamespace({\n namespace: \"cloud-files\",\n parts: [\"userId\"],\n description:\n \"files.files / folders / file_versions + iam.permissions + platform.share_links for one user\",\n});\n\nexport const cloudFilesRealtimeMiddleware: Middleware = (store) => {\n let stopChannel: (() => void) | null = null;\n let subscribedUserId: string | null = null;\n let cancelDeferredBackfill: (() => void) | null = null;\n\n // Local typed dispatcher — `store.dispatch` comes in through the plain\n // `Middleware` API type, which doesn't know about thunk middleware; this\n // middleware dispatches `reconcileTree` (an async thunk), so we widen to\n // the thunk-aware `AppDispatch` once here to keep call sites concise.\n const dispatch = store.dispatch as AppDispatch;\n\n function teardown(): void {\n cancelDeferredBackfill?.();\n cancelDeferredBackfill = null;\n if (stopChannel) {\n stopChannel();\n stopChannel = null;\n }\n subscribedUserId = null;\n dispatch(setRealtimeStatus({ status: \"detached\", userId: null }));\n }\n\n function setup(userId: string): void {\n if (subscribedUserId === userId && stopChannel) return;\n teardown();\n\n subscribedUserId = userId;\n initialSnapshotDoneAt = null;\n dispatch(setRealtimeStatus({ status: \"connecting\", userId }));\n\n const rowId = (row: Record<string, unknown>): string | undefined =>\n typeof row.id === \"string\" ? row.id : undefined;\n\n stopChannel = subscribeToRealtimeManager(() => ({\n topic: cloudFilesChannel.topic({ userId }),\n postgresChanges: [\n // Files — no server-side column filter (a created_by filter would drop\n // updates to explicitly-shared files), BUT RLS alone is NOT the listing\n // boundary: the `pub_read` policy makes every public file SELECT-visible,\n // so handleFilePayload applies the LISTING gate client-side (owner or\n // already-in-store only — mirror of DB `files.is_listable_for`). Never\n // remove that gate; without it public files from other users stream\n // into everyone's tree.\n {\n event: \"*\",\n schema: \"files\",\n table: \"files\",\n rowId,\n onChange: ({ payload }) =>\n handleFilePayload(\n payload as unknown as RealtimePostgresChangesPayload<CloudFileRow>,\n ),\n },\n // Folders — NO owner filter, same RLS-bounded rationale as files.\n {\n event: \"*\",\n schema: \"files\",\n table: \"folders\",\n rowId,\n onChange: ({ payload }) =>\n handleFolderPayload(\n payload as unknown as RealtimePostgresChangesPayload<CloudFolderRow>,\n ),\n },\n // Versions — no owner filter (FK to files.files; RLS enforces).\n {\n event: \"*\",\n schema: \"files\",\n table: \"file_versions\",\n rowId,\n onChange: ({ payload }) =>\n handleVersionPayload(\n payload as unknown as RealtimePostgresChangesPayload<CloudFileVersionRow>,\n ),\n },\n // Permissions — canonical grant store `iam.permissions`. Filtered to\n // grants made TO this user; the handler additionally guards\n // `resource_type === 'file'` since this table holds grants for every\n // resource type. (org-grant rows have no granted_to_user_id, so they\n // won't arrive on this channel — the share panel reload picks them up.)\n {\n event: \"*\",\n schema: \"iam\",\n table: \"permissions\",\n filter: `granted_to_user_id=eq.${userId}`,\n rowId,\n onChange: ({ payload }) =>\n handlePermissionPayload(\n payload as unknown as RealtimePostgresChangesPayload<\n Record<string, unknown>\n >,\n ),\n },\n // Share links — canonical platform.share_links. RLS (owner-only\n // SELECT) bounds delivery to links this user created. Requires the\n // table in the realtime publication.\n {\n event: \"*\",\n schema: \"platform\",\n table: \"share_links\",\n rowId,\n onChange: ({ payload }) =>\n handleShareLinkPayload(\n payload as unknown as RealtimePostgresChangesPayload<\n Record<string, unknown>\n >,\n ),\n },\n ],\n onStatusChange: (status) => {\n dispatch(\n setRealtimeStatus({\n status:\n status === \"connected\"\n ? \"subscribed\"\n : status === \"connecting\"\n ? \"connecting\"\n : \"errored\",\n userId: subscribedUserId,\n }),\n );\n },\n // THE BACKFILL DOOR — reconnect, tab wake, network restore, queue\n // overflow. The old code hung this off SUBSCRIBED only, so a slept tab\n // never reconciled and the tree silently drifted.\n //\n // The cooldown and in-flight guards below are NOT reconnect logic (the\n // package owns that): they exist so a flapping connection cannot storm an\n // expensive tree RPC or revert optimistic edits mid-write. First\n // subscribe reconciles ONLY if the initial snapshot already resolved —\n // its data predates the live channel, which is the gap window; a snapshot\n // still in flight reads post-subscribe state, so firing here would only\n // duplicate the RPC it is racing.\n onBackfill: () => {\n // A hidden tab (asleep, network frozen) must not start a whole-tree\n // reload nobody is watching — it stalls and times out unfelt (class\n // sec_d904494698f4). One deferred backfill runs when the tab returns.\n if (isDocumentHidden()) {\n if (!cancelDeferredBackfill) {\n cancelDeferredBackfill = onceDocumentVisible(() => {\n cancelDeferredBackfill = null;\n backfill();\n });\n }\n return;\n }\n backfill();\n },\n }));\n\n function backfill(): void {\n const now = Date.now();\n if (\n initialSnapshotDoneAt !== null &&\n now - lastReconcileAt >= RECONCILE_COOLDOWN_MS &&\n ledgerSize() === 0\n ) {\n lastReconcileAt = now;\n void dispatch(reconcileTree({ userId }));\n }\n }\n }\n\n // -------------------------------------------------------------------------\n // Payload handlers\n //\n // Every handler below asserts `payload.new`/`payload.old` (typed\n // `Record<string, unknown>` by supabase-js — `.on(\"postgres_changes\", ...)`\n // isn't bound to `Database`, so the client cannot know the row shape) to\n // the generated `Tables<T>[\"Row\"]` type for that table (`CloudFileRow` /\n // `CloudFolderRow` / etc., already Pattern-2 \"table query\" types). This is\n // the realtime-payload analogue of the RPC `DbRpcRow`-guarded cast: there is\n // no row schema the library can check, but the target types are the real\n // generated ground truth, not a hand-mirrored shape.\n // -------------------------------------------------------------------------\n\n function handleFilePayload(\n payload: RealtimePostgresChangesPayload<Record<string, unknown>>,\n ): void {\n dispatch(touchRealtime());\n const requestId = extractRequestId(payload);\n const rowId =\n (payload.new as { id?: string } | undefined)?.id ??\n (payload.old as { id?: string } | undefined)?.id ??\n null;\n if (\n isOwnEcho({\n requestId,\n resourceId: rowId,\n resourceType: \"file\",\n })\n ) {\n return;\n }\n\n if (payload.eventType === \"DELETE\") {\n if (rowId) {\n dispatch(removeFile({ id: rowId }));\n dispatch(clearSelection());\n }\n return;\n }\n\n const newRow = payload.new as unknown as CloudFileRow;\n if (!newRow?.id) return;\n // Drop backend-owned variant rows. The backfill cascade was cleaned\n // up server-side on 2026-05-16 (migrations 012/013) and all live\n // variant rows now carry `parent_file_id` + `derivation_kind`, but\n // realtime payloads still arrive for new variant writes — we filter\n // at the boundary so they never appear in the user tree.\n // See `isSystemPath` + common-docs systems/files/file-service/WIRE_CONTRACT.md.\n if (!isUserVisibleFileRow(newRow)) return;\n // LISTING gate (client mirror of DB `files.is_listable_for`): realtime\n // is RLS-authorized and RLS includes a `pub_read` policy, so without\n // this check every PUBLIC file any other user touches would stream into\n // this user's tree/recents (a shipped leak). Accept a row only if this\n // user owns it, or it's already in the store (it arrived through the\n // listing-gated tree RPC — i.e. an explicit grant).\n const alreadyInStore = Boolean(\n (store.getState() as StateWithCloudFiles).cloudFiles.filesById[newRow.id],\n );\n // component-created-by-ok: files.files / files.folders are entities — created_by is the file's or folder's owner (the listing gate)\n if (newRow.created_by !== subscribedUserId && !alreadyInStore) return;\n const file = dbRowToCloudFile(newRow);\n // `files.files` is REPLICA IDENTITY DEFAULT — `payload.old` carries ONLY\n // the PK on UPDATE/DELETE, so the previous parent must come from OUR\n // store, never from diffing old-vs-new payload rows.\n const oldParent =\n (store.getState() as StateWithCloudFiles).cloudFiles.filesById[file.id]\n ?.parentFolderId ?? null;\n\n dispatch(upsertFile(file));\n\n if (file.deletedAt) {\n dispatch(removeFile({ id: file.id }));\n return;\n }\n\n if (payload.eventType === \"INSERT\") {\n dispatch(\n attachChildToFolder({\n parentFolderId: file.parentFolderId,\n kind: \"file\",\n id: file.id,\n }),\n );\n } else if (payload.eventType === \"UPDATE\") {\n if (oldParent !== file.parentFolderId) {\n dispatch(\n detachChildFromFolder({\n parentFolderId: oldParent,\n kind: \"file\",\n id: file.id,\n }),\n );\n dispatch(\n attachChildToFolder({\n parentFolderId: file.parentFolderId,\n kind: \"file\",\n id: file.id,\n }),\n );\n }\n }\n }\n\n function handleFolderPayload(\n payload: RealtimePostgresChangesPayload<Record<string, unknown>>,\n ): void {\n dispatch(touchRealtime());\n const requestId = extractRequestId(payload);\n const rowId =\n (payload.new as { id?: string } | undefined)?.id ??\n (payload.old as { id?: string } | undefined)?.id ??\n null;\n if (\n isOwnEcho({\n requestId,\n resourceId: rowId,\n resourceType: \"folder\",\n })\n ) {\n return;\n }\n\n if (payload.eventType === \"DELETE\") {\n if (rowId) dispatch(removeFolder({ id: rowId }));\n return;\n }\n\n const newRow = payload.new as unknown as CloudFolderRow;\n if (!newRow?.id) return;\n // Same system-path guard as the file handler — the backfill also\n // creates per-source-file folders under `system-files/variants/<id>/`.\n if (!isUserVisibleFolderPath(newRow.folder_path)) return;\n // Same LISTING gate as files — the tree RPC only ever returns the\n // user's OWN folders, so realtime must not inject anyone else's.\n // component-created-by-ok: files.files / files.folders are entities — created_by is the file's or folder's owner (the listing gate)\n if (newRow.created_by !== subscribedUserId) return;\n const folder = dbRowToCloudFolder(newRow);\n // REPLICA IDENTITY DEFAULT: `payload.old` is PK-only — read the previous\n // parent from our store (see the file handler above).\n const oldParent =\n (store.getState() as StateWithCloudFiles).cloudFiles.foldersById[\n folder.id\n ]?.parentId ?? null;\n\n dispatch(upsertFolder(folder));\n\n if (folder.deletedAt) {\n dispatch(removeFolder({ id: folder.id }));\n return;\n }\n\n if (payload.eventType === \"INSERT\") {\n dispatch(\n attachChildToFolder({\n parentFolderId: folder.parentId,\n kind: \"folder\",\n id: folder.id,\n }),\n );\n } else if (\n payload.eventType === \"UPDATE\" &&\n oldParent !== folder.parentId\n ) {\n dispatch(\n detachChildFromFolder({\n parentFolderId: oldParent,\n kind: \"folder\",\n id: folder.id,\n }),\n );\n dispatch(\n attachChildToFolder({\n parentFolderId: folder.parentId,\n kind: \"folder\",\n id: folder.id,\n }),\n );\n }\n }\n\n function handleVersionPayload(\n payload: RealtimePostgresChangesPayload<Record<string, unknown>>,\n ): void {\n dispatch(touchRealtime());\n const newRow = payload.new as unknown as CloudFileVersionRow | undefined;\n const oldRow = payload.old as unknown as CloudFileVersionRow | undefined;\n const fileId = newRow?.file_id ?? oldRow?.file_id;\n if (!fileId) return;\n\n // For a single event we'd merge into the existing array. Simpler: refetch\n // the list for this file. Cheap query, and keeps order / counts correct.\n const state = store.getState() as StateWithCloudFiles;\n const existing = state.cloudFiles.versionsByFileId[fileId] ?? [];\n\n if (payload.eventType === \"DELETE\") {\n const deletedId = oldRow?.id;\n if (!deletedId) return;\n dispatch(\n upsertVersionsForFile({\n fileId,\n versions: existing.filter((v) => v.id !== deletedId),\n }),\n );\n return;\n }\n\n if (!newRow?.id) return;\n const converted = dbRowToCloudFileVersion(newRow);\n const next = [\n converted,\n ...existing.filter((v) => v.id !== converted.id),\n ].sort((a, b) => b.versionNumber - a.versionNumber);\n dispatch(upsertVersionsForFile({ fileId, versions: next }));\n\n // Cross-device invalidation: if another session uploaded a new\n // version of this file, the bytes in our local blob cache are now\n // stale. Drop the cached entry so the next preview open re-fetches\n // the latest version. (We can't tell from the row alone whether\n // this version is the new \"current\" — but it's safer to drop on\n // any version-row insert than to keep showing old bytes.)\n // The echo of this tab's own save is the exception: the save already\n // cached exactly these bytes as this version, and dropping them would\n // download what was just uploaded.\n if (cachedVersion(fileId) === converted.versionNumber) return;\n void invalidateBlobCache(fileId);\n invalidateOfficeExtraction(fileId);\n }\n\n function handlePermissionPayload(\n payload: RealtimePostgresChangesPayload<Record<string, unknown>>,\n ): void {\n dispatch(touchRealtime());\n const newRow = payload.new as unknown as CloudFilePermissionRow | undefined;\n const oldRow = payload.old as unknown as CloudFilePermissionRow | undefined;\n // `public.permissions` holds grants for every resource type; only react to\n // file grants. (DELETE payloads may only carry the replica-identity cols —\n // if resource_type is absent we fall through on resourceId presence.)\n const resourceType = newRow?.resource_type ?? oldRow?.resource_type;\n if (resourceType && resourceType !== \"file\") return;\n const resourceId = newRow?.resource_id ?? oldRow?.resource_id;\n if (!resourceId) return;\n\n const state = store.getState() as StateWithCloudFiles;\n const existing = state.cloudFiles.permissionsByResourceId[resourceId] ?? [];\n\n if (payload.eventType === \"DELETE\") {\n const deletedId = oldRow?.id;\n if (!deletedId) return;\n dispatch(\n upsertPermissionsForResource({\n resourceId,\n permissions: existing.filter((p) => p.id !== deletedId),\n }),\n );\n return;\n }\n\n if (!newRow?.id) return;\n const converted = dbRowToCloudFilePermission(newRow);\n const next = [converted, ...existing.filter((p) => p.id !== converted.id)];\n dispatch(upsertPermissionsForResource({ resourceId, permissions: next }));\n }\n\n function handleShareLinkPayload(\n payload: RealtimePostgresChangesPayload<Record<string, unknown>>,\n ): void {\n dispatch(touchRealtime());\n const newRow = payload.new as PlatformShareLinkRow | undefined;\n const oldRow = payload.old as PlatformShareLinkRow | undefined;\n // platform.share_links spans EVERY shareable type — this slice only\n // tracks file/folder links.\n const resourceType = newRow?.resource_type ?? oldRow?.resource_type;\n if (resourceType !== \"file\" && resourceType !== \"folder\") return;\n const resourceId = newRow?.resource_id ?? oldRow?.resource_id;\n if (!resourceId) return;\n\n const state = store.getState() as StateWithCloudFiles;\n const existing = state.cloudFiles.shareLinksByResourceId[resourceId] ?? [];\n\n if (payload.eventType === \"DELETE\" || newRow?.is_active === false) {\n const removedId = oldRow?.id ?? newRow?.id;\n if (removedId) {\n dispatch(removeShareLink({ linkId: removedId }));\n }\n return;\n }\n\n if (!newRow?.id || !newRow.token) return;\n const converted = canonicalShareLinkToCloudShareLink(\n {\n id: newRow.id,\n token: newRow.token,\n shortToken: newRow.short_token ?? null,\n permissionLevel: (newRow.permission_level ??\n \"viewer\") as CloudShareLink[\"permissionLevel\"],\n label: newRow.label ?? null,\n expiresAt: newRow.expires_at ?? null,\n maxUses: newRow.max_uses ?? null,\n useCount: newRow.use_count ?? 0,\n isActive: newRow.is_active ?? true,\n createdAt: newRow.created_at ?? null,\n lastUsedAt: newRow.last_used_at ?? null,\n },\n resourceType,\n resourceId,\n );\n const next = [converted, ...existing.filter((l) => l.id !== converted.id)];\n dispatch(upsertShareLinksForResource({ resourceId, shareLinks: next }));\n }\n\n // -------------------------------------------------------------------------\n // Middleware dispatcher\n // -------------------------------------------------------------------------\n\n return (next) => (action) => {\n if (loadUserFileTree.fulfilled.match(action)) {\n initialSnapshotDoneAt = Date.now();\n return next(action);\n }\n if (isAttachAction(action as RealtimeAction)) {\n const userId = (action as AttachAction).payload.userId;\n setup(userId);\n return next(action);\n }\n if (isDetachAction(action as RealtimeAction)) {\n teardown();\n return next(action);\n }\n return next(action);\n };\n};\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* features/files/redux/request-ledger.ts
|
|
3
|
+
*
|
|
4
|
+
* In-memory correlation map between client-generated `requestId`s (attached to
|
|
5
|
+
* every mutation via X-Request-Id) and the realtime payloads they echo.
|
|
6
|
+
*
|
|
7
|
+
* USE
|
|
8
|
+
* ---
|
|
9
|
+
* - Thunks call `registerRequest({ requestId, kind, resourceId, resourceType })`
|
|
10
|
+
* before dispatching a REST write. The optimistic reducer has already
|
|
11
|
+
* applied the change locally.
|
|
12
|
+
* - The realtime middleware calls `isOwnEcho(payload)` on every incoming
|
|
13
|
+
* event. If the payload's `metadata.request_id` matches a live ledger
|
|
14
|
+
* entry, it's our own echo — skip the dispatch. Otherwise the change came
|
|
15
|
+
* from elsewhere (server, other device, share-link visitor) and we apply it.
|
|
16
|
+
* - Entries expire automatically after 30s so stale ids don't swallow
|
|
17
|
+
* legitimate later updates to the same resource. Thunks also explicitly
|
|
18
|
+
* `releaseRequest(requestId)` once the REST call returns (success or error).
|
|
19
|
+
*
|
|
20
|
+
* As of 2026-05-17 the Python backend reliably stamps
|
|
21
|
+
* `metadata.request_id` on every cloud_sync write (commit `d647c143` —
|
|
22
|
+
* see common-docs systems/files/file-service/WIRE_CONTRACT.md). The legacy 2s
|
|
23
|
+
* timestamp-fuzzy fallback was removed at that point — direct
|
|
24
|
+
* supabase writes that don't stamp request_id will trigger an echo
|
|
25
|
+
* re-apply, which is idempotent (upsert keyed on id).
|
|
26
|
+
*/
|
|
27
|
+
import type { LedgerEntry, RequestKind, ResourceType } from "../types.js";
|
|
28
|
+
/**
|
|
29
|
+
* Quick accessor for the full map size — used in DevTools / diagnostics only.
|
|
30
|
+
*/
|
|
31
|
+
export declare function ledgerSize(): number;
|
|
32
|
+
export interface RegisterArgs {
|
|
33
|
+
requestId: string;
|
|
34
|
+
kind: RequestKind;
|
|
35
|
+
resourceId: string | null;
|
|
36
|
+
resourceType: ResourceType | null;
|
|
37
|
+
}
|
|
38
|
+
export declare function registerRequest(args: RegisterArgs): void;
|
|
39
|
+
export declare function releaseRequest(requestId: string): void;
|
|
40
|
+
/**
|
|
41
|
+
* Returns the entry if the given id is a known in-flight request, otherwise
|
|
42
|
+
* null. Does NOT release it — the thunk is responsible for explicit release
|
|
43
|
+
* on REST completion.
|
|
44
|
+
*/
|
|
45
|
+
export declare function getEntry(requestId: string): LedgerEntry | null;
|
|
46
|
+
/**
|
|
47
|
+
* Checks a realtime payload for an own-echo match: explicit
|
|
48
|
+
* `request_id` in the row metadata matches an in-flight ledger entry.
|
|
49
|
+
*
|
|
50
|
+
* Returns true if the caller should SKIP this payload (it's our own echo).
|
|
51
|
+
*/
|
|
52
|
+
export declare function isOwnEcho(payload: {
|
|
53
|
+
requestId: string | null;
|
|
54
|
+
resourceId: string | null;
|
|
55
|
+
resourceType: ResourceType | null;
|
|
56
|
+
}): boolean;
|
|
57
|
+
/** Test/debug helper. */
|
|
58
|
+
export declare function clearLedger(): void;
|
|
59
|
+
/** Begin an op on a resource; returns this op's sequence number. */
|
|
60
|
+
export declare function beginResourceOp(resourceId: string): number;
|
|
61
|
+
/** True if `seq` is still the most recent op begun for `resourceId`. */
|
|
62
|
+
export declare function isLatestResourceOp(resourceId: string, seq: number): boolean;
|
|
63
|
+
/**
|
|
64
|
+
* True if a live ledger entry of `kind` already targets `resourceId` — used to
|
|
65
|
+
* suppress duplicate non-idempotent submits (e.g. a second "create share link"
|
|
66
|
+
* click that would mint a second token).
|
|
67
|
+
*/
|
|
68
|
+
export declare function hasInFlight(resourceId: string, kind: RequestKind): boolean;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
const ENTRY_TTL_MS = 3e4;
|
|
2
|
+
const ledger = /* @__PURE__ */ new Map();
|
|
3
|
+
function ledgerSize() {
|
|
4
|
+
return ledger.size;
|
|
5
|
+
}
|
|
6
|
+
function registerRequest(args) {
|
|
7
|
+
releaseRequest(args.requestId);
|
|
8
|
+
const entry = {
|
|
9
|
+
requestId: args.requestId,
|
|
10
|
+
kind: args.kind,
|
|
11
|
+
resourceId: args.resourceId,
|
|
12
|
+
resourceType: args.resourceType,
|
|
13
|
+
createdAt: Date.now(),
|
|
14
|
+
_timer: setTimeout(() => {
|
|
15
|
+
ledger.delete(args.requestId);
|
|
16
|
+
}, ENTRY_TTL_MS)
|
|
17
|
+
};
|
|
18
|
+
ledger.set(args.requestId, entry);
|
|
19
|
+
}
|
|
20
|
+
function releaseRequest(requestId) {
|
|
21
|
+
const existing = ledger.get(requestId);
|
|
22
|
+
if (!existing) return;
|
|
23
|
+
clearTimeout(existing._timer);
|
|
24
|
+
ledger.delete(requestId);
|
|
25
|
+
}
|
|
26
|
+
function getEntry(requestId) {
|
|
27
|
+
const e = ledger.get(requestId);
|
|
28
|
+
if (!e) return null;
|
|
29
|
+
const { _timer: _t, ...rest } = e;
|
|
30
|
+
return rest;
|
|
31
|
+
}
|
|
32
|
+
function isOwnEcho(payload) {
|
|
33
|
+
return Boolean(payload.requestId && ledger.has(payload.requestId));
|
|
34
|
+
}
|
|
35
|
+
function clearLedger() {
|
|
36
|
+
for (const entry of ledger.values()) clearTimeout(entry._timer);
|
|
37
|
+
ledger.clear();
|
|
38
|
+
resourceSeq.clear();
|
|
39
|
+
}
|
|
40
|
+
const resourceSeq = /* @__PURE__ */ new Map();
|
|
41
|
+
function beginResourceOp(resourceId) {
|
|
42
|
+
const next = (resourceSeq.get(resourceId) ?? 0) + 1;
|
|
43
|
+
resourceSeq.set(resourceId, next);
|
|
44
|
+
return next;
|
|
45
|
+
}
|
|
46
|
+
function isLatestResourceOp(resourceId, seq) {
|
|
47
|
+
return (resourceSeq.get(resourceId) ?? 0) === seq;
|
|
48
|
+
}
|
|
49
|
+
function hasInFlight(resourceId, kind) {
|
|
50
|
+
for (const e of ledger.values()) {
|
|
51
|
+
if (e.resourceId === resourceId && e.kind === kind) return true;
|
|
52
|
+
}
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
export {
|
|
56
|
+
beginResourceOp,
|
|
57
|
+
clearLedger,
|
|
58
|
+
getEntry,
|
|
59
|
+
hasInFlight,
|
|
60
|
+
isLatestResourceOp,
|
|
61
|
+
isOwnEcho,
|
|
62
|
+
ledgerSize,
|
|
63
|
+
registerRequest,
|
|
64
|
+
releaseRequest
|
|
65
|
+
};
|
|
66
|
+
//# sourceMappingURL=request-ledger.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../src/files/engine/redux/request-ledger.ts"],"sourcesContent":["/**\n * features/files/redux/request-ledger.ts\n *\n * In-memory correlation map between client-generated `requestId`s (attached to\n * every mutation via X-Request-Id) and the realtime payloads they echo.\n *\n * USE\n * ---\n * - Thunks call `registerRequest({ requestId, kind, resourceId, resourceType })`\n * before dispatching a REST write. The optimistic reducer has already\n * applied the change locally.\n * - The realtime middleware calls `isOwnEcho(payload)` on every incoming\n * event. If the payload's `metadata.request_id` matches a live ledger\n * entry, it's our own echo — skip the dispatch. Otherwise the change came\n * from elsewhere (server, other device, share-link visitor) and we apply it.\n * - Entries expire automatically after 30s so stale ids don't swallow\n * legitimate later updates to the same resource. Thunks also explicitly\n * `releaseRequest(requestId)` once the REST call returns (success or error).\n *\n * As of 2026-05-17 the Python backend reliably stamps\n * `metadata.request_id` on every cloud_sync write (commit `d647c143` —\n * see common-docs systems/files/file-service/WIRE_CONTRACT.md). The legacy 2s\n * timestamp-fuzzy fallback was removed at that point — direct\n * supabase writes that don't stamp request_id will trigger an echo\n * re-apply, which is idempotent (upsert keyed on id).\n */\n\nimport type {\n LedgerEntry,\n RequestKind,\n ResourceType,\n} from \"../types\";\n\nconst ENTRY_TTL_MS = 30_000;\n\ntype EntryWithTimer = LedgerEntry & { _timer: ReturnType<typeof setTimeout> };\n\nconst ledger = new Map<string, EntryWithTimer>();\n\n/**\n * Quick accessor for the full map size — used in DevTools / diagnostics only.\n */\nexport function ledgerSize(): number {\n return ledger.size;\n}\n\nexport interface RegisterArgs {\n requestId: string;\n kind: RequestKind;\n resourceId: string | null;\n resourceType: ResourceType | null;\n}\n\nexport function registerRequest(args: RegisterArgs): void {\n // If this id is already in flight (unlikely — UUIDs), refresh the timer.\n releaseRequest(args.requestId);\n\n const entry: EntryWithTimer = {\n requestId: args.requestId,\n kind: args.kind,\n resourceId: args.resourceId,\n resourceType: args.resourceType,\n createdAt: Date.now(),\n _timer: setTimeout(() => {\n ledger.delete(args.requestId);\n }, ENTRY_TTL_MS),\n };\n ledger.set(args.requestId, entry);\n}\n\nexport function releaseRequest(requestId: string): void {\n const existing = ledger.get(requestId);\n if (!existing) return;\n clearTimeout(existing._timer);\n ledger.delete(requestId);\n}\n\n/**\n * Returns the entry if the given id is a known in-flight request, otherwise\n * null. Does NOT release it — the thunk is responsible for explicit release\n * on REST completion.\n */\nexport function getEntry(requestId: string): LedgerEntry | null {\n const e = ledger.get(requestId);\n if (!e) return null;\n const { _timer: _t, ...rest } = e;\n return rest;\n}\n\n/**\n * Checks a realtime payload for an own-echo match: explicit\n * `request_id` in the row metadata matches an in-flight ledger entry.\n *\n * Returns true if the caller should SKIP this payload (it's our own echo).\n */\nexport function isOwnEcho(payload: {\n requestId: string | null;\n resourceId: string | null;\n resourceType: ResourceType | null;\n}): boolean {\n return Boolean(payload.requestId && ledger.has(payload.requestId));\n}\n\n/** Test/debug helper. */\nexport function clearLedger(): void {\n for (const entry of ledger.values()) clearTimeout(entry._timer);\n ledger.clear();\n resourceSeq.clear();\n}\n\n// ---------------------------------------------------------------------------\n// Per-resource operation sequencing (P1-1 double-submit, P1-2 out-of-order)\n// ---------------------------------------------------------------------------\n//\n// Each mutating thunk calls `beginResourceOp(resourceId)` at the start to get a\n// monotonically increasing sequence number for that resource, then — before\n// applying the authoritative server response — checks `isLatestResourceOp`.\n// If a newer op for the same resource has begun since, the older response is\n// stale and must NOT overwrite the newer optimistic state (rename A→B→C where\n// C resolves before B; or a double-click firing two writes). The newest op's\n// own optimistic update + response is the source of truth.\n\nconst resourceSeq = new Map<string, number>();\n\n/** Begin an op on a resource; returns this op's sequence number. */\nexport function beginResourceOp(resourceId: string): number {\n const next = (resourceSeq.get(resourceId) ?? 0) + 1;\n resourceSeq.set(resourceId, next);\n return next;\n}\n\n/** True if `seq` is still the most recent op begun for `resourceId`. */\nexport function isLatestResourceOp(resourceId: string, seq: number): boolean {\n return (resourceSeq.get(resourceId) ?? 0) === seq;\n}\n\n/**\n * True if a live ledger entry of `kind` already targets `resourceId` — used to\n * suppress duplicate non-idempotent submits (e.g. a second \"create share link\"\n * click that would mint a second token).\n */\nexport function hasInFlight(\n resourceId: string,\n kind: RequestKind,\n): boolean {\n for (const e of ledger.values()) {\n if (e.resourceId === resourceId && e.kind === kind) return true;\n }\n return false;\n}\n"],"mappings":"AAiCA,MAAM,eAAe;AAIrB,MAAM,SAAS,oBAAI,IAA4B;AAKxC,SAAS,aAAqB;AACnC,SAAO,OAAO;AAChB;AASO,SAAS,gBAAgB,MAA0B;AAExD,iBAAe,KAAK,SAAS;AAE7B,QAAM,QAAwB;AAAA,IAC5B,WAAW,KAAK;AAAA,IAChB,MAAM,KAAK;AAAA,IACX,YAAY,KAAK;AAAA,IACjB,cAAc,KAAK;AAAA,IACnB,WAAW,KAAK,IAAI;AAAA,IACpB,QAAQ,WAAW,MAAM;AACvB,aAAO,OAAO,KAAK,SAAS;AAAA,IAC9B,GAAG,YAAY;AAAA,EACjB;AACA,SAAO,IAAI,KAAK,WAAW,KAAK;AAClC;AAEO,SAAS,eAAe,WAAyB;AACtD,QAAM,WAAW,OAAO,IAAI,SAAS;AACrC,MAAI,CAAC,SAAU;AACf,eAAa,SAAS,MAAM;AAC5B,SAAO,OAAO,SAAS;AACzB;AAOO,SAAS,SAAS,WAAuC;AAC9D,QAAM,IAAI,OAAO,IAAI,SAAS;AAC9B,MAAI,CAAC,EAAG,QAAO;AACf,QAAM,EAAE,QAAQ,IAAI,GAAG,KAAK,IAAI;AAChC,SAAO;AACT;AAQO,SAAS,UAAU,SAId;AACV,SAAO,QAAQ,QAAQ,aAAa,OAAO,IAAI,QAAQ,SAAS,CAAC;AACnE;AAGO,SAAS,cAAoB;AAClC,aAAW,SAAS,OAAO,OAAO,EAAG,cAAa,MAAM,MAAM;AAC9D,SAAO,MAAM;AACb,cAAY,MAAM;AACpB;AAcA,MAAM,cAAc,oBAAI,IAAoB;AAGrC,SAAS,gBAAgB,YAA4B;AAC1D,QAAM,QAAQ,YAAY,IAAI,UAAU,KAAK,KAAK;AAClD,cAAY,IAAI,YAAY,IAAI;AAChC,SAAO;AACT;AAGO,SAAS,mBAAmB,YAAoB,KAAsB;AAC3E,UAAQ,YAAY,IAAI,UAAU,KAAK,OAAO;AAChD;AAOO,SAAS,YACd,YACA,MACS;AACT,aAAW,KAAK,OAAO,OAAO,GAAG;AAC/B,QAAI,EAAE,eAAe,cAAc,EAAE,SAAS,KAAM,QAAO;AAAA,EAC7D;AACA,SAAO;AACT;","names":[]}
|