@anchrd/intel 0.59.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/README.md +496 -0
- package/bin/intel.mjs +134 -0
- package/dist/adapters/cloudflare/cloudflare-flow-workflow.d.ts +42 -0
- package/dist/adapters/cloudflare/cloudflare-flow-workflow.js +89 -0
- package/dist/adapters/cloudflare/cloudflare.d.ts +29 -0
- package/dist/adapters/cloudflare/cloudflare.js +416 -0
- package/dist/adapters/cloudflare/cloudflare.types.d.ts +31 -0
- package/dist/adapters/cloudflare/cloudflare.types.js +1 -0
- package/dist/adapters/content/content.d.ts +3 -0
- package/dist/adapters/content/content.js +128 -0
- package/dist/adapters/content/content.types.d.ts +38 -0
- package/dist/adapters/content/content.types.js +1 -0
- package/dist/adapters/db/db-audit.d.ts +62 -0
- package/dist/adapters/db/db-audit.js +150 -0
- package/dist/adapters/db/db-boards.d.ts +6 -0
- package/dist/adapters/db/db-boards.js +324 -0
- package/dist/adapters/db/db-feed.d.ts +30 -0
- package/dist/adapters/db/db-feed.js +276 -0
- package/dist/adapters/db/db-flows.d.ts +42 -0
- package/dist/adapters/db/db-flows.js +1311 -0
- package/dist/adapters/db/db-grants.d.ts +133 -0
- package/dist/adapters/db/db-grants.js +269 -0
- package/dist/adapters/db/db-indexing.d.ts +3 -0
- package/dist/adapters/db/db-indexing.js +200 -0
- package/dist/adapters/db/db-oauth.d.ts +3 -0
- package/dist/adapters/db/db-oauth.js +26 -0
- package/dist/adapters/db/db-prompts.d.ts +46 -0
- package/dist/adapters/db/db-prompts.js +144 -0
- package/dist/adapters/db/db.d.ts +6 -0
- package/dist/adapters/db/db.js +1687 -0
- package/dist/adapters/db/db.types.d.ts +16 -0
- package/dist/adapters/db/db.types.js +1 -0
- package/dist/adapters/document-converter/document-converter.d.ts +2 -0
- package/dist/adapters/document-converter/document-converter.js +26 -0
- package/dist/adapters/document-converter/document-converter.types.d.ts +17 -0
- package/dist/adapters/document-converter/document-converter.types.js +1 -0
- package/dist/adapters/flow-runtime/flow-runtime.d.ts +3 -0
- package/dist/adapters/flow-runtime/flow-runtime.js +22 -0
- package/dist/adapters/flow-runtime/flow-runtime.types.d.ts +19 -0
- package/dist/adapters/flow-runtime/flow-runtime.types.js +1 -0
- package/dist/adapters/index-queue/index-queue.d.ts +10 -0
- package/dist/adapters/index-queue/index-queue.js +12 -0
- package/dist/adapters/index-queue/index-queue.types.d.ts +11 -0
- package/dist/adapters/index-queue/index-queue.types.js +1 -0
- package/dist/adapters/json-schema/json-schema.d.ts +2 -0
- package/dist/adapters/json-schema/json-schema.js +77 -0
- package/dist/adapters/openid/openid.d.ts +5 -0
- package/dist/adapters/openid/openid.js +301 -0
- package/dist/adapters/portal-tokens/portal-tokens.d.ts +8 -0
- package/dist/adapters/portal-tokens/portal-tokens.js +79 -0
- package/dist/adapters/remote-tools/remote-tools.d.ts +5 -0
- package/dist/adapters/remote-tools/remote-tools.js +129 -0
- package/dist/adapters/semantic-index/semantic-index.d.ts +2 -0
- package/dist/adapters/semantic-index/semantic-index.js +139 -0
- package/dist/adapters/semantic-index/semantic-index.types.d.ts +56 -0
- package/dist/adapters/semantic-index/semantic-index.types.js +1 -0
- package/dist/adapters/session-cookie/session-cookie.d.ts +5 -0
- package/dist/adapters/session-cookie/session-cookie.js +87 -0
- package/dist/adapters/tool-source-policy/tool-source-policy.d.ts +1 -0
- package/dist/adapters/tool-source-policy/tool-source-policy.js +22 -0
- package/dist/audit/audit.d.ts +2 -0
- package/dist/audit/audit.js +87 -0
- package/dist/audit/audit.types.d.ts +23 -0
- package/dist/audit/audit.types.js +1 -0
- package/dist/auth/auth.d.ts +2 -0
- package/dist/auth/auth.js +284 -0
- package/dist/auth/auth.types.d.ts +108 -0
- package/dist/auth/auth.types.js +1 -0
- package/dist/boards/boards.d.ts +44 -0
- package/dist/boards/boards.js +447 -0
- package/dist/boards/boards.types.d.ts +117 -0
- package/dist/boards/boards.types.js +1 -0
- package/dist/build/build.d.ts +2 -0
- package/dist/build/build.js +192 -0
- package/dist/build/build.types.d.ts +17 -0
- package/dist/build/build.types.js +1 -0
- package/dist/bundle/bundle.d.ts +4 -0
- package/dist/bundle/bundle.js +1194 -0
- package/dist/bundle/bundle.types.d.ts +33 -0
- package/dist/bundle/bundle.types.js +1 -0
- package/dist/cli/cli.d.ts +4 -0
- package/dist/cli/cli.js +262 -0
- package/dist/cli/cli.types.d.ts +14 -0
- package/dist/cli/cli.types.js +1 -0
- package/dist/contract/audit.d.ts +50 -0
- package/dist/contract/audit.js +72 -0
- package/dist/contract/board.d.ts +150 -0
- package/dist/contract/board.js +262 -0
- package/dist/contract/bundle.d.ts +147 -0
- package/dist/contract/bundle.js +139 -0
- package/dist/contract/contract.d.ts +112 -0
- package/dist/contract/contract.js +143 -0
- package/dist/contract/feed.d.ts +126 -0
- package/dist/contract/feed.js +149 -0
- package/dist/contract/flow-run.d.ts +346 -0
- package/dist/contract/flow-run.js +194 -0
- package/dist/contract/flow.d.ts +1057 -0
- package/dist/contract/flow.js +552 -0
- package/dist/contract/node.d.ts +551 -0
- package/dist/contract/node.js +456 -0
- package/dist/contract/share.d.ts +216 -0
- package/dist/contract/share.js +134 -0
- package/dist/contract/table.d.ts +171 -0
- package/dist/contract/table.js +117 -0
- package/dist/contract/tool.d.ts +122 -0
- package/dist/contract/tool.js +172 -0
- package/dist/feed/feed.d.ts +2 -0
- package/dist/feed/feed.js +77 -0
- package/dist/feed/feed.types.d.ts +23 -0
- package/dist/feed/feed.types.js +1 -0
- package/dist/flows/flows.d.ts +41 -0
- package/dist/flows/flows.js +2216 -0
- package/dist/flows/flows.types.d.ts +339 -0
- package/dist/flows/flows.types.js +1 -0
- package/dist/http/http.d.ts +9 -0
- package/dist/http/http.js +874 -0
- package/dist/http/http.types.d.ts +22 -0
- package/dist/http/http.types.js +1 -0
- package/dist/indexing/indexing.d.ts +6 -0
- package/dist/indexing/indexing.js +239 -0
- package/dist/indexing/indexing.types.d.ts +11 -0
- package/dist/indexing/indexing.types.js +1 -0
- package/dist/intel/intel.d.ts +3 -0
- package/dist/intel/intel.js +194 -0
- package/dist/intel/intel.types.d.ts +24 -0
- package/dist/intel/intel.types.js +1 -0
- package/dist/mcp/mcp.d.ts +2 -0
- package/dist/mcp/mcp.js +1292 -0
- package/dist/mcp/mcp.types.d.ts +25 -0
- package/dist/mcp/mcp.types.js +1 -0
- package/dist/nodes/document-links/document-links.d.ts +20 -0
- package/dist/nodes/document-links/document-links.js +57 -0
- package/dist/nodes/nodes.d.ts +2 -0
- package/dist/nodes/nodes.js +1667 -0
- package/dist/nodes/nodes.types.d.ts +511 -0
- package/dist/nodes/nodes.types.js +1 -0
- package/dist/prepare/prepare.d.ts +4 -0
- package/dist/prepare/prepare.js +16 -0
- package/dist/prepare/prepare.types.d.ts +9 -0
- package/dist/prepare/prepare.types.js +1 -0
- package/dist/prompts/prompts.d.ts +2 -0
- package/dist/prompts/prompts.js +65 -0
- package/dist/prompts/prompts.types.d.ts +71 -0
- package/dist/prompts/prompts.types.js +1 -0
- package/dist/shared/callers/callers.d.ts +28 -0
- package/dist/shared/callers/callers.js +30 -0
- package/dist/shared/csv/csv.d.ts +13 -0
- package/dist/shared/csv/csv.js +85 -0
- package/dist/shared/document-text/document-text.d.ts +21 -0
- package/dist/shared/document-text/document-text.js +31 -0
- package/dist/shared/gate-authorization/gate-authorization.d.ts +5 -0
- package/dist/shared/gate-authorization/gate-authorization.js +26 -0
- package/dist/shared/grant-expiry/grant-expiry.d.ts +19 -0
- package/dist/shared/grant-expiry/grant-expiry.js +26 -0
- package/dist/shared/intel-error/intel-error.d.ts +22 -0
- package/dist/shared/intel-error/intel-error.js +27 -0
- package/dist/shared/plain-title/plain-title.d.ts +16 -0
- package/dist/shared/plain-title/plain-title.js +45 -0
- package/dist/shared/problem-details/problem-details.d.ts +30 -0
- package/dist/shared/problem-details/problem-details.js +18 -0
- package/dist/shared/report-unexpected-error/report-unexpected-error.d.ts +1 -0
- package/dist/shared/report-unexpected-error/report-unexpected-error.js +37 -0
- package/dist/shared/safe-return-path/safe-return-path.d.ts +2 -0
- package/dist/shared/safe-return-path/safe-return-path.js +11 -0
- package/dist/shared/sha256/sha256.d.ts +2 -0
- package/dist/shared/sha256/sha256.js +14 -0
- package/dist/tools/tool-servers/tool-servers.d.ts +46 -0
- package/dist/tools/tool-servers/tool-servers.js +177 -0
- package/dist/tools/tools.d.ts +2 -0
- package/dist/tools/tools.js +207 -0
- package/dist/tools/tools.types.d.ts +56 -0
- package/dist/tools/tools.types.js +1 -0
- package/examples/branding/favicon.svg +5 -0
- package/examples/branding/logo.svg +5 -0
- package/examples/branding/theme.css +16 -0
- package/examples/dev.vars.example +7 -0
- package/examples/intel.json +9 -0
- package/migrations/0000_intel_foundation.sql +203 -0
- package/migrations/0001_portal_native_tools.sql +21 -0
- package/migrations/0002_flows_in_the_knowledge_tree.sql +34 -0
- package/migrations/0003_folder_permissions.sql +211 -0
- package/migrations/0004_subflow_runs.sql +14 -0
- package/migrations/0005_flow_node_cleanup.sql +28 -0
- package/migrations/0005_tables_in_the_knowledge_tree.sql +68 -0
- package/migrations/0006_links_are_written_in_the_text.sql +20 -0
- package/migrations/0007_no_node_waits.sql +69 -0
- package/migrations/0008_three_layers.sql +130 -0
- package/migrations/0009_no_context_policy.sql +48 -0
- package/migrations/0010_no_run_waits_a_year.sql +27 -0
- package/migrations/0011_one_name_for_the_tree.sql +53 -0
- package/migrations/0012_table_snapshots.sql +29 -0
- package/migrations/0017_a_vector_per_card.sql +38 -0
- package/migrations/0018_no_context_policy_at_last.sql +97 -0
- package/migrations/0019_one_name_for_the_grants.sql +52 -0
- package/migrations/0020_cascade_purge_replay.sql +11 -0
- package/migrations/0021_a_flow_carries_its_own_grant.sql +44 -0
- package/migrations/0022_a_cursor_over_the_journal.sql +14 -0
- package/migrations/0023_a_board_and_its_tasks.sql +116 -0
- package/migrations/0024_the_archive_is_a_column.sql +33 -0
- package/migrations/0025_archiving_takes_the_subtree.sql +29 -0
- package/migrations/0026_one_feed_over_two_kinds.sql +23 -0
- package/migrations/0027_the_runs_of_every_flow.sql +27 -0
- package/migrations/0028_a_prompt_name_over_two_kinds.sql +70 -0
- package/package.json +118 -0
- package/src/contract/CLAUDE.md +152 -0
- package/src/contract/README.md +102 -0
- package/src/contract/audit.ts +85 -0
- package/src/contract/board.ts +306 -0
- package/src/contract/bundle.ts +157 -0
- package/src/contract/contract.ts +161 -0
- package/src/contract/feed.ts +165 -0
- package/src/contract/flow-run.ts +241 -0
- package/src/contract/flow.ts +675 -0
- package/src/contract/node.ts +583 -0
- package/src/contract/share.ts +196 -0
- package/src/contract/table.ts +178 -0
- package/src/contract/tool.ts +202 -0
- package/ui/README.md +110 -0
- package/ui/components.json +21 -0
- package/ui/index.html +13 -0
- package/ui/package.json +11 -0
- package/ui/scripts/lint-tokens.mjs +187 -0
- package/ui/src/access-summary/access-summary.tsx +385 -0
- package/ui/src/app/action-slot/action-slot.tsx +27 -0
- package/ui/src/app/app-sidebar/app-sidebar.tsx +71 -0
- package/ui/src/app/app-tree/app-tree.tsx +1175 -0
- package/ui/src/app/app.tsx +87 -0
- package/ui/src/app/header-search/header-search.tsx +294 -0
- package/ui/src/app/reindex-dialog/reindex-dialog.tsx +79 -0
- package/ui/src/app/settings-dialog/settings-dialog.tsx +135 -0
- package/ui/src/app/sidebar-preferences/sidebar-preferences.ts +68 -0
- package/ui/src/app/sidebar-preferences/sidebar-preferences.types.ts +15 -0
- package/ui/src/app/sidebar-resize-handle/sidebar-resize-handle.tsx +86 -0
- package/ui/src/app/tree-expansion/tree-expansion.ts +107 -0
- package/ui/src/app/tree-expansion/tree-expansion.types.ts +31 -0
- package/ui/src/app/tree-move/tree-move.tsx +427 -0
- package/ui/src/app/tree-reveal/tree-reveal.ts +64 -0
- package/ui/src/app/tree-reveal/tree-reveal.types.ts +16 -0
- package/ui/src/app/user-footer/user-footer.tsx +188 -0
- package/ui/src/app/view-menu/view-menu.tsx +115 -0
- package/ui/src/app-root/app-root.tsx +109 -0
- package/ui/src/app-root/app-root.types.ts +27 -0
- package/ui/src/archive/archive.tsx +417 -0
- package/ui/src/attachment-detail/attachment-detail.ts +52 -0
- package/ui/src/attachment-viewer/attachment-viewer.tsx +166 -0
- package/ui/src/blocknote-view/blocknote-view.tsx +30 -0
- package/ui/src/board/board-assignee/board-assignee-picker.tsx +132 -0
- package/ui/src/board/board-assignee/board-assignee.ts +48 -0
- package/ui/src/board/board-assignee/board-assignee.tsx +75 -0
- package/ui/src/board/board-chip/board-chip.tsx +46 -0
- package/ui/src/board/board-crumbs/board-crumbs.ts +56 -0
- package/ui/src/board/board-crumbs/board-crumbs.tsx +111 -0
- package/ui/src/board/board-data/board-data.ts +195 -0
- package/ui/src/board/board-data/board-data.types.ts +38 -0
- package/ui/src/board/board-dates/board-dates.ts +40 -0
- package/ui/src/board/board-dates/board-dates.tsx +105 -0
- package/ui/src/board/board-kanban/board-kanban.ts +162 -0
- package/ui/src/board/board-kanban/board-kanban.tsx +680 -0
- package/ui/src/board/board-open-task/board-open-task.ts +32 -0
- package/ui/src/board/board-panel/board-panel.tsx +259 -0
- package/ui/src/board/board-settings/board-settings.tsx +226 -0
- package/ui/src/board/board-status/board-status.tsx +60 -0
- package/ui/src/board/board-stripes/board-stripes.ts +128 -0
- package/ui/src/board/board-table/board-table.ts +53 -0
- package/ui/src/board/board-table/board-table.tsx +610 -0
- package/ui/src/board/board-task/board-task.ts +108 -0
- package/ui/src/board/board-task/board-task.tsx +335 -0
- package/ui/src/board/board-title-row/board-title-row.tsx +66 -0
- package/ui/src/branding/branding.tsx +17 -0
- package/ui/src/branding/custom-logo.ts +1 -0
- package/ui/src/branding/favicon.default.svg +5 -0
- package/ui/src/branding/favicon.svg +5 -0
- package/ui/src/components/ui/avatar.tsx +39 -0
- package/ui/src/components/ui/button.tsx +64 -0
- package/ui/src/components/ui/collapsible.tsx +20 -0
- package/ui/src/components/ui/command.tsx +160 -0
- package/ui/src/components/ui/dialog.tsx +143 -0
- package/ui/src/components/ui/dropdown-menu.tsx +187 -0
- package/ui/src/components/ui/input.tsx +21 -0
- package/ui/src/components/ui/popover.tsx +41 -0
- package/ui/src/components/ui/select.tsx +163 -0
- package/ui/src/components/ui/separator.tsx +26 -0
- package/ui/src/components/ui/sheet.tsx +136 -0
- package/ui/src/components/ui/sidebar.tsx +693 -0
- package/ui/src/components/ui/skeleton.tsx +13 -0
- package/ui/src/components/ui/switch.tsx +25 -0
- package/ui/src/components/ui/table.tsx +82 -0
- package/ui/src/components/ui/tabs.tsx +52 -0
- package/ui/src/components/ui/tooltip.tsx +51 -0
- package/ui/src/data/intel-data-provider/intel-data-provider.ts +685 -0
- package/ui/src/data/intel-data-provider/intel-data-provider.types.ts +266 -0
- package/ui/src/data/query-client.ts +18 -0
- package/ui/src/data/request-refusal/refusal-notice.tsx +36 -0
- package/ui/src/data/request-refusal/request-refusal.ts +51 -0
- package/ui/src/data/sign-in/sign-in.ts +97 -0
- package/ui/src/data/sign-in/sign-in.types.ts +15 -0
- package/ui/src/document-link/document-link.tsx +114 -0
- package/ui/src/document-markdown/document-markdown.ts +195 -0
- package/ui/src/editor-schema/editor-schema.ts +23 -0
- package/ui/src/entry-picker/entry-picker.tsx +238 -0
- package/ui/src/feed/feed.tsx +344 -0
- package/ui/src/file-preview/file-preview-view.tsx +496 -0
- package/ui/src/file-preview/file-preview.tsx +28 -0
- package/ui/src/file-preview/pdf-file-preview.tsx +5 -0
- package/ui/src/file-preview/presentation-file-preview.tsx +21 -0
- package/ui/src/file-preview/spreadsheet-file-preview.tsx +5 -0
- package/ui/src/file-preview/word-file-preview.tsx +5 -0
- package/ui/src/flow-runs/flow-runs.tsx +238 -0
- package/ui/src/flows/flows.tsx +1646 -0
- package/ui/src/flows/node-icon/node-icon.ts +34 -0
- package/ui/src/flows/node-palette/node-palette.tsx +214 -0
- package/ui/src/flows/node-palette/node-palette.types.ts +15 -0
- package/ui/src/folder-contents/folder-contents.tsx +321 -0
- package/ui/src/folder-index/folder-index.ts +55 -0
- package/ui/src/frontmatter/frontmatter.tsx +193 -0
- package/ui/src/graph-pane/graph-pane.tsx +53 -0
- package/ui/src/hooks/use-mobile.ts +19 -0
- package/ui/src/i18n/custom.json +4 -0
- package/ui/src/i18n/de.json +609 -0
- package/ui/src/i18n/en.json +609 -0
- package/ui/src/i18n/es.json +609 -0
- package/ui/src/i18n/i18n-context.tsx +65 -0
- package/ui/src/i18n/i18n-languages/i18n-languages.ts +83 -0
- package/ui/src/i18n/i18n.ts +32 -0
- package/ui/src/i18n/i18n.types.ts +49 -0
- package/ui/src/kind-icon.ts +97 -0
- package/ui/src/lib/utils.ts +6 -0
- package/ui/src/main.tsx +68 -0
- package/ui/src/modal/modal.tsx +53 -0
- package/ui/src/node-details/node-details.tsx +545 -0
- package/ui/src/node-editor/node-editor.tsx +304 -0
- package/ui/src/node-graph/graph-notice.tsx +21 -0
- package/ui/src/node-graph/node-graph.ts +214 -0
- package/ui/src/node-graph/node-graph.tsx +295 -0
- package/ui/src/node-import/node-import.tsx +418 -0
- package/ui/src/node-save-error.ts +46 -0
- package/ui/src/node-source/node-source.tsx +128 -0
- package/ui/src/node-table/node-table.tsx +107 -0
- package/ui/src/nodes/nodes.tsx +240 -0
- package/ui/src/rename-resource/rename-resource.ts +80 -0
- package/ui/src/resolved-names/resolved-names.tsx +67 -0
- package/ui/src/resource-error.ts +33 -0
- package/ui/src/resource-menu/resource-menu.tsx +1156 -0
- package/ui/src/router/router-context.ts +6 -0
- package/ui/src/router/router.tsx +67 -0
- package/ui/src/router/router.types.ts +8 -0
- package/ui/src/router/selection-search.ts +88 -0
- package/ui/src/save-button/save-button.tsx +94 -0
- package/ui/src/section-hint/section-hint.tsx +40 -0
- package/ui/src/sign-in-refused/sign-in-refused.tsx +35 -0
- package/ui/src/styles.css +189 -0
- package/ui/src/table-columns/table-columns.tsx +379 -0
- package/ui/src/theme/custom.css +1 -0
- package/ui/src/theme/theme-context.tsx +89 -0
- package/ui/src/theme/theme.ts +70 -0
- package/ui/src/time/relative-time.tsx +41 -0
- package/ui/src/time/time-context.tsx +78 -0
- package/ui/src/time/time.ts +165 -0
- package/ui/src/title-row/title-row.tsx +185 -0
- package/ui/src/tools/tools.tsx +546 -0
- package/ui/src/user-name/user-name.ts +61 -0
- package/ui/src/verbatim/verbatim.tsx +68 -0
- package/ui/tsconfig.json +17 -0
- package/ui/vite.config.ts +129 -0
|
@@ -0,0 +1,1687 @@
|
|
|
1
|
+
import { descendantsCte, flowInSubtree, flowInSubtreeBindings, grantColumns as grantColumnsFor, grantInForce, mapGrant, nodeVerbBindings, nodeVerbQuery, principalColumns, subtreeBindings, subtreeCte, } from "./db-grants.js";
|
|
2
|
+
import { promptNameHolder } from "./db-prompts.js";
|
|
3
|
+
const versionColumns = `id, node_id, sequence, content_key, media_type, content_hash,
|
|
4
|
+
size, segment, created_by, created_at`;
|
|
5
|
+
const grantColumns = grantColumnsFor("node_id");
|
|
6
|
+
const linkColumns = `link.id, link.source_node_id, link.target_node_id, link.relation,
|
|
7
|
+
link.origin, link.label, link.created_by, link.created_at`;
|
|
8
|
+
// The first row of each node, in the order they arrive. A chunked kind — one whose index holds
|
|
9
|
+
// one row per task (#285) — answers a query once per matching card; a citation names a node, so the
|
|
10
|
+
// best of them is the one that gets to speak for it.
|
|
11
|
+
function dedupedByNode(rows) {
|
|
12
|
+
const seen = new Set();
|
|
13
|
+
return rows.filter((row) => {
|
|
14
|
+
if (seen.has(row.node_id))
|
|
15
|
+
return false;
|
|
16
|
+
seen.add(row.node_id);
|
|
17
|
+
return true;
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
const nodeColumns = `n.id, n.parent_id, n.kind, n.title, n.description,
|
|
21
|
+
n.owner_id, n.current_version_id, n.created_at, n.updated_at, n.archived_at, n.prompt_name`;
|
|
22
|
+
function mapNode(row) {
|
|
23
|
+
return {
|
|
24
|
+
id: row.id,
|
|
25
|
+
parentId: row.parent_id,
|
|
26
|
+
kind: row.kind,
|
|
27
|
+
title: row.title,
|
|
28
|
+
description: row.description,
|
|
29
|
+
ownerId: row.owner_id,
|
|
30
|
+
currentVersionId: row.current_version_id,
|
|
31
|
+
createdAt: row.created_at,
|
|
32
|
+
updatedAt: row.updated_at,
|
|
33
|
+
archivedAt: row.archived_at,
|
|
34
|
+
promptName: row.prompt_name,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function mapVersion(row) {
|
|
38
|
+
return {
|
|
39
|
+
id: row.id,
|
|
40
|
+
nodeId: row.node_id,
|
|
41
|
+
sequence: row.sequence,
|
|
42
|
+
contentKey: row.content_key,
|
|
43
|
+
mediaType: row.media_type,
|
|
44
|
+
contentHash: row.content_hash,
|
|
45
|
+
size: row.size,
|
|
46
|
+
segment: row.segment,
|
|
47
|
+
createdBy: row.created_by,
|
|
48
|
+
createdAt: row.created_at,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function mapLink(row) {
|
|
52
|
+
return {
|
|
53
|
+
id: row.id,
|
|
54
|
+
sourceNodeId: row.source_node_id,
|
|
55
|
+
targetNodeId: row.target_node_id,
|
|
56
|
+
relation: row.relation,
|
|
57
|
+
origin: row.origin,
|
|
58
|
+
label: row.label,
|
|
59
|
+
createdBy: row.created_by,
|
|
60
|
+
createdAt: row.created_at,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
function toFtsQuery(query) {
|
|
64
|
+
return query
|
|
65
|
+
.split(/\s+/)
|
|
66
|
+
.filter(Boolean)
|
|
67
|
+
.map((token) => `"${token.replaceAll('"', '""')}"`)
|
|
68
|
+
.join(" AND ");
|
|
69
|
+
}
|
|
70
|
+
export function createNodeRepository(deps) {
|
|
71
|
+
// Reading is one verb among four; the tree walk itself lives in db-grants.ts so Flows asks the
|
|
72
|
+
// same question of the same table.
|
|
73
|
+
const visibleCte = subtreeCte;
|
|
74
|
+
const readBindings = (actor) => subtreeBindings(actor, "read", deps.now().toISOString());
|
|
75
|
+
/**
|
|
76
|
+
* What a LEVEL query binds: the walk over `nodes`, then the flow half of `has_children`, in the
|
|
77
|
+
* order the two appear in the statement.
|
|
78
|
+
*
|
|
79
|
+
* ⚠️ One clock reading for both halves. Two calls to `deps.now()` inside one statement would
|
|
80
|
+
* measure the same `expires_at` against two different moments, and a grant that expires between
|
|
81
|
+
* them would be honoured on one side of the OR and refused on the other.
|
|
82
|
+
*/
|
|
83
|
+
const levelBindings = (actor) => {
|
|
84
|
+
const now = deps.now().toISOString();
|
|
85
|
+
return [...subtreeBindings(actor, "read", now), ...flowInSubtreeBindings(actor, "read", now)];
|
|
86
|
+
};
|
|
87
|
+
/**
|
|
88
|
+
* The optional folder cut a search carries (#126), in the three places one statement needs it:
|
|
89
|
+
* the extra CTE, the extra join, and the one binding between the actor's and the query's. All
|
|
90
|
+
* three are absent together when there is no scope, so an unscoped search runs the exact
|
|
91
|
+
* statement it ran before.
|
|
92
|
+
*
|
|
93
|
+
* ⚠️ The join is added *beside* `allowed`, never instead of it. `descendants` is seeded from a
|
|
94
|
+
* caller-supplied id and knows nothing about grants; on its own it would answer "everything under
|
|
95
|
+
* this folder" to anyone who names the folder.
|
|
96
|
+
*/
|
|
97
|
+
const scopeCte = (scopeId) => scopeId === undefined ? "" : `,\n${descendantsCte}`;
|
|
98
|
+
const scopeJoin = (scopeId) => scopeId === undefined ? "" : "\n JOIN descendants ON descendants.id = n.id";
|
|
99
|
+
const scopeBindings = (scopeId) => (scopeId === undefined ? [] : [scopeId]);
|
|
100
|
+
/**
|
|
101
|
+
* Which rows belong to the level being asked for.
|
|
102
|
+
*
|
|
103
|
+
* ⚠️ At the ROOT this is not `parent_id IS NULL` but "the top of every region this actor may
|
|
104
|
+
* read" (#429). A grant on a nested folder has no entry point otherwise: the way to it leads
|
|
105
|
+
* through a folder the recipient may not see, so the level it would appear in can never be
|
|
106
|
+
* opened, and the share stays usable only for whoever passes the URL around by hand. It is the
|
|
107
|
+
* same seed `listVisibleSubtree` already starts a whole-installation export from — written once
|
|
108
|
+
* more rather than differently.
|
|
109
|
+
*
|
|
110
|
+
* ⚠️ It says nothing about the way there. A row carries its own id and its own title, both of
|
|
111
|
+
* which `getVisible` already answers to this actor; the ancestors it hangs under are never read,
|
|
112
|
+
* named or counted, so the title of an unreadable parent folder stays where it was.
|
|
113
|
+
*
|
|
114
|
+
* ⚠️ Both `?` take the SAME value, and the second is not redundant: SQLite binds by position, so
|
|
115
|
+
* asking "was the root asked for" needs its own placeholder rather than a second reading of the
|
|
116
|
+
* first.
|
|
117
|
+
*/
|
|
118
|
+
const levelPredicate = `(
|
|
119
|
+
n.parent_id IS ?
|
|
120
|
+
OR (? IS NULL AND n.parent_id NOT IN (SELECT id FROM allowed))
|
|
121
|
+
)`;
|
|
122
|
+
/**
|
|
123
|
+
* One statement for the level the tree reads and for the bounded read the relation graph makes,
|
|
124
|
+
* so the visibility predicate cannot drift between them.
|
|
125
|
+
*
|
|
126
|
+
* ⚠️ In the bounded form `LIMIT` follows the `WHERE`, so the cut falls among the rows this actor
|
|
127
|
+
* may see and never before them, and `COUNT(*) OVER ()` counts those same rows alone (#30).
|
|
128
|
+
*/
|
|
129
|
+
/**
|
|
130
|
+
* The kinds the FOLDER TREE does not show. A task belongs to its board and is reached there
|
|
131
|
+
* (D66, #376); the tree would otherwise flood with three hundred cards, which is the objection
|
|
132
|
+
* that sank the node-per-task idea the first time it was proposed.
|
|
133
|
+
*
|
|
134
|
+
* ⚠️ It is a cut in the STATEMENT, not a filter in the interface. Filtered later, the rows have
|
|
135
|
+
* already crossed the wire — titles of other people's tasks inside an answer that was not
|
|
136
|
+
* supposed to carry them.
|
|
137
|
+
*
|
|
138
|
+
* ⚠️ It says nothing about reachability. A task keeps its own address, its own grants and its own
|
|
139
|
+
* place in search and in the relation graph; only this one level query looks away. Hiding it
|
|
140
|
+
* everywhere would make a link to a task a dead end.
|
|
141
|
+
*/
|
|
142
|
+
const notInTree = (alias) => `${alias}.kind <> 'task'`;
|
|
143
|
+
/**
|
|
144
|
+
* The chevron column, and it is drawn ONLY for the tree (#777). The bounded read the relation
|
|
145
|
+
* graph makes discards it, and since #777 it is no longer free: computing it costs a second
|
|
146
|
+
* correlated `EXISTS` per row and six bindings that statement has no other use for. Leaving it
|
|
147
|
+
* out there is not a divergence in the visibility predicate, which is what the two forms share;
|
|
148
|
+
* it is one column the caller never reads.
|
|
149
|
+
*/
|
|
150
|
+
const chevronColumn = `
|
|
151
|
+
-- Whether this row has children THIS actor may see (#59), asked of the same allowed set one
|
|
152
|
+
-- level down. A second rule written here would drift from the one above it, and the drift
|
|
153
|
+
-- would show as a chevron that opens onto nothing.
|
|
154
|
+
--
|
|
155
|
+
-- ⚠️ The same cut here as below, and it is the SAME rule read twice rather
|
|
156
|
+
-- than a second one: a board's children are all tasks, so with them hidden it has none this
|
|
157
|
+
-- actor can see, and the chevron falls away by itself (D66, #376). Leaving it out here would
|
|
158
|
+
-- draw a chevron that opens onto an empty level — exactly the drift the comment above warns
|
|
159
|
+
-- about, arriving through the door it was written for.
|
|
160
|
+
--
|
|
161
|
+
-- ⚠️ And the SECOND half of the level, because the tree is shared: a folder holds nodes and
|
|
162
|
+
-- flows side by side (ADR-0004 §1), and a flow is not a node. Asking only the nodes table
|
|
163
|
+
-- left a folder whose only children are flows answering "empty": no chevron, no way down,
|
|
164
|
+
-- while clicking the folder listed both kinds perfectly well (#777). It is the same root as
|
|
165
|
+
-- #457, where a count over nodes alone held such a folder for empty on the delete path; that
|
|
166
|
+
-- one was fixed, this one was not.
|
|
167
|
+
--
|
|
168
|
+
-- ⚠️ flowInSubtree rather than a hand-written condition, for the reason stated above it: it
|
|
169
|
+
-- is the very predicate db-flows.ts draws the level with, so the chevron and the level it
|
|
170
|
+
-- opens onto cannot answer differently. Its folder clause is already satisfied here (the
|
|
171
|
+
-- flow's parent is n, and n is joined to allowed), so today it can only widen the answer,
|
|
172
|
+
-- never narrow it, which is the only direction a chevron may drift in at all.
|
|
173
|
+
(EXISTS (
|
|
174
|
+
SELECT 1 FROM nodes child
|
|
175
|
+
JOIN allowed AS allowed_child ON allowed_child.id = child.id
|
|
176
|
+
WHERE child.parent_id = n.id AND child.archived_at IS NULL AND ${notInTree("child")}
|
|
177
|
+
) OR EXISTS (
|
|
178
|
+
SELECT 1 FROM flows flow
|
|
179
|
+
WHERE flow.parent_id = n.id AND flow.archived_at IS NULL AND ${flowInSubtree}
|
|
180
|
+
)) AS has_children`;
|
|
181
|
+
const visibleChildren = (bounded) => `${visibleCte}
|
|
182
|
+
SELECT ${nodeColumns}${bounded ? ", COUNT(*) OVER () AS total" : `,${chevronColumn}`}
|
|
183
|
+
FROM nodes n
|
|
184
|
+
JOIN allowed ON allowed.id = n.id
|
|
185
|
+
WHERE ${levelPredicate} AND (? = 1 OR n.archived_at IS NULL) AND ${notInTree("n")}
|
|
186
|
+
ORDER BY CASE n.kind WHEN 'folder' THEN 0 ELSE 1 END, lower(n.title), n.id${bounded ? " LIMIT ?" : ""}`;
|
|
187
|
+
/**
|
|
188
|
+
* Everything archived that this actor may see, wherever it sits (#113).
|
|
189
|
+
*
|
|
190
|
+
* ⚠️ No `parent_id` at all, and that is the point rather than an omission: somebody looking for
|
|
191
|
+
* what they archived does not know which folder it was in. The visibility predicate is the same
|
|
192
|
+
* `allowed` set every other read uses, so the archive shows no more than the tree would have.
|
|
193
|
+
*
|
|
194
|
+
* `has_children` is answered as 0 without asking: an archived row is not expandable — its
|
|
195
|
+
* children are reached by restoring it, not by opening it here.
|
|
196
|
+
*/
|
|
197
|
+
const archivedEverywhere = `${visibleCte}
|
|
198
|
+
SELECT ${nodeColumns}, 0 AS has_children
|
|
199
|
+
FROM nodes n
|
|
200
|
+
JOIN allowed ON allowed.id = n.id
|
|
201
|
+
WHERE n.archived_at IS NOT NULL
|
|
202
|
+
ORDER BY n.archived_at DESC, lower(n.title), n.id`;
|
|
203
|
+
return {
|
|
204
|
+
async listVisible(actor, input) {
|
|
205
|
+
const result = await deps.db
|
|
206
|
+
.prepare(input.archivedOnly ? archivedEverywhere : visibleChildren(false))
|
|
207
|
+
.bind(
|
|
208
|
+
// ⚠️ The archive statement answers `has_children` as a literal 0, so it carries no flow
|
|
209
|
+
// bindings; the level statement carries both halves. Same branch, two bind lists.
|
|
210
|
+
...(input.archivedOnly
|
|
211
|
+
? readBindings(actor)
|
|
212
|
+
: [
|
|
213
|
+
...levelBindings(actor),
|
|
214
|
+
input.parentId,
|
|
215
|
+
input.parentId,
|
|
216
|
+
input.includeArchived ? 1 : 0,
|
|
217
|
+
]))
|
|
218
|
+
.all();
|
|
219
|
+
const rows = result.results ?? [];
|
|
220
|
+
return {
|
|
221
|
+
items: rows.map(mapNode),
|
|
222
|
+
withChildren: rows.filter((row) => row.has_children === 1).map((row) => row.id),
|
|
223
|
+
};
|
|
224
|
+
},
|
|
225
|
+
async listVisibleBounded(actor, input) {
|
|
226
|
+
const result = await deps.db
|
|
227
|
+
.prepare(visibleChildren(true))
|
|
228
|
+
// ⚠️ `readBindings`, not `levelBindings`: this form carries no chevron column, so the
|
|
229
|
+
// flow half's six bindings have no placeholder to land in. D1 answers a miscount with
|
|
230
|
+
// `Wrong number of parameter bindings`, so the two cannot silently go out of step.
|
|
231
|
+
.bind(...readBindings(actor), input.parentId, input.parentId, 0, input.limit)
|
|
232
|
+
.all();
|
|
233
|
+
const rows = result.results ?? [];
|
|
234
|
+
return { items: rows.map(mapNode), total: rows[0]?.total ?? 0 };
|
|
235
|
+
},
|
|
236
|
+
async getVisible(actor, nodeId) {
|
|
237
|
+
const row = await deps.db
|
|
238
|
+
.prepare(`${visibleCte}
|
|
239
|
+
SELECT ${nodeColumns}
|
|
240
|
+
FROM nodes n
|
|
241
|
+
JOIN allowed ON allowed.id = n.id
|
|
242
|
+
WHERE n.id = ?`)
|
|
243
|
+
.bind(...readBindings(actor), nodeId)
|
|
244
|
+
.first();
|
|
245
|
+
return row ? mapNode(row) : null;
|
|
246
|
+
},
|
|
247
|
+
/**
|
|
248
|
+
* The readable subtree as one statement (#136): the `allowed` walk says what the actor may
|
|
249
|
+
* read, and `walk` says what hangs together under the chosen root. Both joins apply at every
|
|
250
|
+
* step, so the walk neither descends into an archived node nor through an unreadable one — a
|
|
251
|
+
* child the actor may read under a folder they may not stays out, because the bundle would have
|
|
252
|
+
* no path to file it under.
|
|
253
|
+
*
|
|
254
|
+
* ⚠️ `UNION`, never `UNION ALL`, for the reason `nodeVerbQuery` gives: a cycle in `parent_id`
|
|
255
|
+
* can reach the table and this walks downwards into it.
|
|
256
|
+
*/
|
|
257
|
+
async listVisibleSubtree(actor, rootId) {
|
|
258
|
+
const seed = rootId === null
|
|
259
|
+
? // "Everything this actor may read": the top of every readable region, not only the top
|
|
260
|
+
// of the tree. A reader who holds a grant on one nested folder has that folder as the
|
|
261
|
+
// top of what they see, and their whole-installation export starts there.
|
|
262
|
+
"(seed.parent_id IS NULL OR seed.parent_id NOT IN (SELECT id FROM allowed))"
|
|
263
|
+
: // The root node itself is the seed; whether it is a folder is the service's question.
|
|
264
|
+
"seed.id = ?";
|
|
265
|
+
const result = await deps.db
|
|
266
|
+
.prepare(`${visibleCte},
|
|
267
|
+
walk(id) AS (
|
|
268
|
+
SELECT seed.id FROM nodes seed
|
|
269
|
+
JOIN allowed ON allowed.id = seed.id
|
|
270
|
+
WHERE ${seed} AND seed.archived_at IS NULL
|
|
271
|
+
UNION
|
|
272
|
+
SELECT child.id FROM nodes child
|
|
273
|
+
JOIN walk ON child.parent_id = walk.id
|
|
274
|
+
JOIN allowed AS allowed_child ON allowed_child.id = child.id
|
|
275
|
+
WHERE child.archived_at IS NULL
|
|
276
|
+
)
|
|
277
|
+
SELECT ${nodeColumns},
|
|
278
|
+
version.id AS version_id, version.media_type, version.content_key
|
|
279
|
+
FROM nodes n
|
|
280
|
+
JOIN walk ON walk.id = n.id
|
|
281
|
+
LEFT JOIN node_versions version ON version.id = n.current_version_id`)
|
|
282
|
+
.bind(...readBindings(actor), ...(rootId === null ? [] : [rootId]))
|
|
283
|
+
.all();
|
|
284
|
+
return (result.results ?? []).map((row) => ({
|
|
285
|
+
node: mapNode(row),
|
|
286
|
+
version: row.version_id === null || row.media_type === null || row.content_key === null
|
|
287
|
+
? null
|
|
288
|
+
: { id: row.version_id, mediaType: row.media_type, contentKey: row.content_key },
|
|
289
|
+
}));
|
|
290
|
+
},
|
|
291
|
+
/**
|
|
292
|
+
* One bundle import as one transaction (#137). Every table takes its rows from a single JSON
|
|
293
|
+
* parameter through `json_each`, so the statement count stays constant however large the
|
|
294
|
+
* bundle is — the same reason `idList` exists in db-flows — and `db.batch` makes the whole
|
|
295
|
+
* import land or refuse as one.
|
|
296
|
+
*
|
|
297
|
+
* ⚠️ `nodes` arrive parents before children: `parent_id REFERENCES nodes(id)` is checked as
|
|
298
|
+
* each row lands, and a child before its parent would abort the batch that is otherwise valid.
|
|
299
|
+
* The service owns that order; this only writes what it is handed.
|
|
300
|
+
*/
|
|
301
|
+
async importTree(input) {
|
|
302
|
+
const statements = [
|
|
303
|
+
deps.db
|
|
304
|
+
.prepare(`INSERT INTO nodes (
|
|
305
|
+
id, parent_id, kind, title, description, owner_id,
|
|
306
|
+
current_version_id, created_at, updated_at, archived_at
|
|
307
|
+
) SELECT
|
|
308
|
+
json_extract(value, '$.id'), json_extract(value, '$.parentId'),
|
|
309
|
+
json_extract(value, '$.kind'), json_extract(value, '$.title'),
|
|
310
|
+
json_extract(value, '$.description'),
|
|
311
|
+
json_extract(value, '$.ownerId'), json_extract(value, '$.currentVersionId'),
|
|
312
|
+
json_extract(value, '$.createdAt'), json_extract(value, '$.updatedAt'), NULL
|
|
313
|
+
FROM json_each(?)`)
|
|
314
|
+
.bind(JSON.stringify(input.nodes)),
|
|
315
|
+
deps.db
|
|
316
|
+
.prepare(`INSERT INTO node_versions (
|
|
317
|
+
id, node_id, sequence, content_key, media_type, content_hash, size, segment,
|
|
318
|
+
created_by, created_at
|
|
319
|
+
) SELECT
|
|
320
|
+
json_extract(value, '$.id'), json_extract(value, '$.nodeId'),
|
|
321
|
+
json_extract(value, '$.sequence'), json_extract(value, '$.contentKey'),
|
|
322
|
+
json_extract(value, '$.mediaType'), json_extract(value, '$.contentHash'),
|
|
323
|
+
json_extract(value, '$.size'), json_extract(value, '$.segment'),
|
|
324
|
+
json_extract(value, '$.createdBy'), json_extract(value, '$.createdAt')
|
|
325
|
+
FROM json_each(?)`)
|
|
326
|
+
.bind(JSON.stringify(input.versions)),
|
|
327
|
+
deps.db
|
|
328
|
+
.prepare(`INSERT INTO node_links (
|
|
329
|
+
id, source_node_id, target_node_id, relation, origin, label, created_by, created_at
|
|
330
|
+
) SELECT
|
|
331
|
+
json_extract(value, '$.id'), json_extract(value, '$.sourceNodeId'),
|
|
332
|
+
json_extract(value, '$.targetNodeId'), 'references', 'text', NULL, ?, ?
|
|
333
|
+
FROM json_each(?)`)
|
|
334
|
+
.bind(input.actorId, input.occurredAt, JSON.stringify(input.links)),
|
|
335
|
+
deps.db
|
|
336
|
+
.prepare(`INSERT INTO flows (
|
|
337
|
+
id, parent_id, title, description, owner_id, current_version_id,
|
|
338
|
+
published_version_id, created_at, updated_at, archived_at
|
|
339
|
+
) SELECT
|
|
340
|
+
json_extract(value, '$.id'), json_extract(value, '$.parentId'),
|
|
341
|
+
json_extract(value, '$.title'), json_extract(value, '$.description'),
|
|
342
|
+
json_extract(value, '$.ownerId'), json_extract(value, '$.currentVersionId'),
|
|
343
|
+
json_extract(value, '$.publishedVersionId'), json_extract(value, '$.createdAt'),
|
|
344
|
+
json_extract(value, '$.updatedAt'), NULL
|
|
345
|
+
FROM json_each(?)`)
|
|
346
|
+
.bind(JSON.stringify(input.flows)),
|
|
347
|
+
deps.db
|
|
348
|
+
.prepare(`INSERT INTO flow_versions (
|
|
349
|
+
id, flow_id, sequence, graph_json, created_by, created_at
|
|
350
|
+
) SELECT
|
|
351
|
+
json_extract(value, '$.id'), json_extract(value, '$.flowId'),
|
|
352
|
+
-- json_extract of an object answers with its JSON text, which is exactly what the
|
|
353
|
+
-- graph_json column stores.
|
|
354
|
+
json_extract(value, '$.sequence'), json_extract(value, '$.graph'),
|
|
355
|
+
json_extract(value, '$.createdBy'), json_extract(value, '$.createdAt')
|
|
356
|
+
FROM json_each(?)`)
|
|
357
|
+
.bind(JSON.stringify(input.flowVersions)),
|
|
358
|
+
// ⚠️ The idempotency row stores the AUDIT id, unlike its siblings that store the resource:
|
|
359
|
+
// one import creates many resources, and the collective audit event is where the summary a
|
|
360
|
+
// replay answers with is kept.
|
|
361
|
+
deps.db
|
|
362
|
+
.prepare(`INSERT INTO idempotency_keys (
|
|
363
|
+
actor_id, operation, idempotency_key, resource_id, created_at
|
|
364
|
+
) VALUES (?, 'node.import', ?, ?, ?)`)
|
|
365
|
+
.bind(input.actorId, input.idempotencyKey, input.auditId, input.occurredAt),
|
|
366
|
+
deps.db
|
|
367
|
+
.prepare(`INSERT INTO audit_events (
|
|
368
|
+
id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
|
|
369
|
+
) VALUES (?, ?, 'node.import', 'node', ?, ?, ?)`)
|
|
370
|
+
.bind(input.auditId, input.actorId, input.auditResourceId, JSON.stringify(input.metadata), input.occurredAt),
|
|
371
|
+
];
|
|
372
|
+
try {
|
|
373
|
+
await deps.db.batch(statements);
|
|
374
|
+
return "created";
|
|
375
|
+
}
|
|
376
|
+
catch (error) {
|
|
377
|
+
const replayed = await this.findImportReplay(input.actorId, input.idempotencyKey);
|
|
378
|
+
if (replayed !== null)
|
|
379
|
+
return "replayed";
|
|
380
|
+
throw error;
|
|
381
|
+
}
|
|
382
|
+
},
|
|
383
|
+
/**
|
|
384
|
+
* What an earlier write with this key recorded about itself (#285).
|
|
385
|
+
*
|
|
386
|
+
* ⚠️ The idempotency row points at the VERSION, and the audit event points at the node, so the
|
|
387
|
+
* two are joined through the version id the snapshot writer puts into the metadata. That
|
|
388
|
+
* metadata is where a snapshot says which positions it touched and how many it removed —
|
|
389
|
+
* facts a replay cannot recompute from the stored document, because the document is the state
|
|
390
|
+
* AFTER the write. Without this, repeating a delete would answer with a count nobody measured.
|
|
391
|
+
*/
|
|
392
|
+
async findSnapshotMetadata(actorId, operation, idempotencyKey) {
|
|
393
|
+
const row = await deps.db
|
|
394
|
+
.prepare(`SELECT audit.metadata_json AS metadata_json
|
|
395
|
+
FROM idempotency_keys keys
|
|
396
|
+
JOIN audit_events audit
|
|
397
|
+
ON audit.action = keys.operation
|
|
398
|
+
AND json_extract(audit.metadata_json, '$.versionId') = keys.resource_id
|
|
399
|
+
WHERE keys.actor_id = ? AND keys.operation = ? AND keys.idempotency_key = ?`)
|
|
400
|
+
.bind(actorId, operation, idempotencyKey)
|
|
401
|
+
.first();
|
|
402
|
+
if (!row)
|
|
403
|
+
return null;
|
|
404
|
+
const parsed = JSON.parse(row.metadata_json);
|
|
405
|
+
return typeof parsed === "object" && parsed !== null
|
|
406
|
+
? parsed
|
|
407
|
+
: null;
|
|
408
|
+
},
|
|
409
|
+
async findImportReplay(actorId, idempotencyKey) {
|
|
410
|
+
const row = await deps.db
|
|
411
|
+
.prepare(`SELECT audit.metadata_json AS metadata_json
|
|
412
|
+
FROM idempotency_keys keys
|
|
413
|
+
JOIN audit_events audit ON audit.id = keys.resource_id
|
|
414
|
+
WHERE keys.actor_id = ? AND keys.operation = 'node.import'
|
|
415
|
+
AND keys.idempotency_key = ?`)
|
|
416
|
+
.bind(actorId, idempotencyKey)
|
|
417
|
+
.first();
|
|
418
|
+
if (!row)
|
|
419
|
+
return null;
|
|
420
|
+
const parsed = JSON.parse(row.metadata_json);
|
|
421
|
+
return typeof parsed === "object" && parsed !== null
|
|
422
|
+
? parsed
|
|
423
|
+
: null;
|
|
424
|
+
},
|
|
425
|
+
async can(actor, nodeId, verb) {
|
|
426
|
+
const row = await deps.db
|
|
427
|
+
.prepare(nodeVerbQuery)
|
|
428
|
+
.bind(...nodeVerbBindings(nodeId, actor, verb, deps.now().toISOString()))
|
|
429
|
+
.first();
|
|
430
|
+
return row?.allowed === 1;
|
|
431
|
+
},
|
|
432
|
+
async findIdempotentNode(actorId, operation, idempotencyKey) {
|
|
433
|
+
const row = await deps.db
|
|
434
|
+
.prepare(`SELECT resource_id FROM idempotency_keys
|
|
435
|
+
WHERE actor_id = ? AND operation = ? AND idempotency_key = ?`)
|
|
436
|
+
.bind(actorId, operation, idempotencyKey)
|
|
437
|
+
.first();
|
|
438
|
+
return row?.resource_id ?? null;
|
|
439
|
+
},
|
|
440
|
+
async findIdempotentRevocation(actorId, idempotencyKey) {
|
|
441
|
+
const row = await deps.db
|
|
442
|
+
.prepare(`SELECT resource_id FROM idempotency_keys
|
|
443
|
+
WHERE actor_id = ? AND operation = 'node.revoke' AND idempotency_key = ?`)
|
|
444
|
+
.bind(actorId, idempotencyKey)
|
|
445
|
+
.first();
|
|
446
|
+
if (!row)
|
|
447
|
+
return null;
|
|
448
|
+
return row.resource_id.startsWith("1:");
|
|
449
|
+
},
|
|
450
|
+
async insertNode(input) {
|
|
451
|
+
const node = input.node;
|
|
452
|
+
try {
|
|
453
|
+
await deps.db.batch([
|
|
454
|
+
deps.db
|
|
455
|
+
.prepare(`INSERT INTO nodes (
|
|
456
|
+
id, parent_id, kind, title, description, owner_id,
|
|
457
|
+
current_version_id, created_at, updated_at, archived_at
|
|
458
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
459
|
+
.bind(node.id, node.parentId, node.kind, node.title, node.description, node.ownerId, node.currentVersionId, node.createdAt, node.updatedAt, node.archivedAt),
|
|
460
|
+
deps.db
|
|
461
|
+
.prepare(`INSERT INTO idempotency_keys (
|
|
462
|
+
actor_id, operation, idempotency_key, resource_id, created_at
|
|
463
|
+
) VALUES (?, 'node.create', ?, ?, ?)`)
|
|
464
|
+
.bind(input.actorId, input.idempotencyKey, node.id, node.createdAt),
|
|
465
|
+
deps.db
|
|
466
|
+
.prepare(`INSERT INTO audit_events (
|
|
467
|
+
id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
|
|
468
|
+
) VALUES (?, ?, 'node.create', 'node', ?, ?, ?)`)
|
|
469
|
+
.bind(input.auditId, input.actorId, node.id, JSON.stringify({ kind: node.kind, parentId: node.parentId }), node.createdAt),
|
|
470
|
+
]);
|
|
471
|
+
}
|
|
472
|
+
catch (error) {
|
|
473
|
+
const replayedKey = await deps.db
|
|
474
|
+
.prepare(`SELECT resource_id FROM idempotency_keys
|
|
475
|
+
WHERE actor_id = ? AND operation = 'node.create' AND idempotency_key = ?`)
|
|
476
|
+
.bind(input.actorId, input.idempotencyKey)
|
|
477
|
+
.first();
|
|
478
|
+
if (replayedKey) {
|
|
479
|
+
const replayed = await deps.db
|
|
480
|
+
.prepare(`${visibleCte}
|
|
481
|
+
SELECT ${nodeColumns}
|
|
482
|
+
FROM nodes n
|
|
483
|
+
JOIN allowed ON allowed.id = n.id
|
|
484
|
+
WHERE n.id = ?`)
|
|
485
|
+
.bind(...readBindings({ id: input.actorId, email: "" }), replayedKey.resource_id)
|
|
486
|
+
.first();
|
|
487
|
+
if (replayed)
|
|
488
|
+
return mapNode(replayed);
|
|
489
|
+
}
|
|
490
|
+
throw error;
|
|
491
|
+
}
|
|
492
|
+
return node;
|
|
493
|
+
},
|
|
494
|
+
async getVersion(versionId) {
|
|
495
|
+
const row = await deps.db
|
|
496
|
+
.prepare(`SELECT ${versionColumns} FROM node_versions WHERE id = ?`)
|
|
497
|
+
.bind(versionId)
|
|
498
|
+
.first();
|
|
499
|
+
return row ? mapVersion(row) : null;
|
|
500
|
+
},
|
|
501
|
+
// From the newest snapshot on (#135). `COALESCE(..., 0)` keeps a table from before snapshots
|
|
502
|
+
// readable in full: without any snapshot row every segment still counts.
|
|
503
|
+
async listVersionContentKeys(nodeId) {
|
|
504
|
+
const result = await deps.db
|
|
505
|
+
.prepare(`SELECT content_key FROM node_versions
|
|
506
|
+
WHERE node_id = ? AND sequence >= COALESCE((
|
|
507
|
+
SELECT MAX(sequence) FROM node_versions
|
|
508
|
+
WHERE node_id = ? AND segment = 'snapshot'
|
|
509
|
+
), 0)
|
|
510
|
+
ORDER BY sequence`)
|
|
511
|
+
.bind(nodeId, nodeId)
|
|
512
|
+
.all();
|
|
513
|
+
return (result.results ?? []).map((row) => row.content_key);
|
|
514
|
+
},
|
|
515
|
+
async tableHeaderContentKey(nodeId) {
|
|
516
|
+
const row = await deps.db
|
|
517
|
+
.prepare(`SELECT content_key FROM node_versions
|
|
518
|
+
WHERE node_id = ? AND segment = 'snapshot'
|
|
519
|
+
ORDER BY sequence DESC LIMIT 1`)
|
|
520
|
+
.bind(nodeId)
|
|
521
|
+
.first();
|
|
522
|
+
return row?.content_key ?? null;
|
|
523
|
+
},
|
|
524
|
+
async listVersions(nodeId) {
|
|
525
|
+
const result = await deps.db
|
|
526
|
+
.prepare(`SELECT ${versionColumns}
|
|
527
|
+
FROM node_versions
|
|
528
|
+
WHERE node_id = ?
|
|
529
|
+
ORDER BY sequence DESC`)
|
|
530
|
+
.bind(nodeId)
|
|
531
|
+
.all();
|
|
532
|
+
return (result.results ?? []).map(mapVersion);
|
|
533
|
+
},
|
|
534
|
+
/**
|
|
535
|
+
* ⚠️ **The prompt name is the one field here whose write can fail for a reason that is not a
|
|
536
|
+
* conflict, and its guard sits ON the statement rather than in front of it** (#775). A
|
|
537
|
+
* `SELECT` beforehand decides on a state that may be gone by the time the `UPDATE` lands; the
|
|
538
|
+
* condition below is evaluated inside the same transaction as the write it guards.
|
|
539
|
+
*
|
|
540
|
+
* ⚠️ **And it is on every statement of the batch, not only the first** — `destructive.md`,
|
|
541
|
+
* `anchrd/intel#457`. A statement matching zero rows is not an error and the batch commits
|
|
542
|
+
* anyway, so the idempotency row and the audit row carry the same `prompt_name IS ?` the
|
|
543
|
+
* `UPDATE` carries. Without it a refused rename would still be booked as done, and the replay
|
|
544
|
+
* path would hand the caller back a node it never wrote.
|
|
545
|
+
*
|
|
546
|
+
* ⚠️ `other.id <> ?` is what lets a node keep its OWN name. Writing the name it already holds
|
|
547
|
+
* is the everyday case — every `node_update` that changes a title sends the current prompt
|
|
548
|
+
* name along — and without the exclusion the node would be refused for colliding with itself.
|
|
549
|
+
*/
|
|
550
|
+
async updateNode(input) {
|
|
551
|
+
const node = input.node;
|
|
552
|
+
try {
|
|
553
|
+
await deps.db.batch([
|
|
554
|
+
deps.db
|
|
555
|
+
.prepare(`WITH RECURSIVE descendants(id) AS (
|
|
556
|
+
SELECT id FROM nodes WHERE id = ?
|
|
557
|
+
UNION ALL
|
|
558
|
+
SELECT child.id
|
|
559
|
+
FROM nodes child
|
|
560
|
+
JOIN descendants parent ON child.parent_id = parent.id
|
|
561
|
+
)
|
|
562
|
+
UPDATE nodes
|
|
563
|
+
SET parent_id = ?, title = ?, description = ?, prompt_name = ?, updated_at = ?
|
|
564
|
+
WHERE id = ? AND updated_at = ?
|
|
565
|
+
AND (? IS NULL OR ? NOT IN (SELECT id FROM descendants))
|
|
566
|
+
AND (
|
|
567
|
+
? IS NULL
|
|
568
|
+
OR (
|
|
569
|
+
NOT EXISTS (
|
|
570
|
+
SELECT 1 FROM nodes other WHERE other.prompt_name = ? AND other.id <> ?
|
|
571
|
+
)
|
|
572
|
+
AND NOT EXISTS (SELECT 1 FROM flows holder WHERE holder.prompt_name = ?)
|
|
573
|
+
)
|
|
574
|
+
)`)
|
|
575
|
+
.bind(node.id, node.parentId, node.title, node.description, node.promptName, node.updatedAt, node.id, input.baseUpdatedAt, node.parentId, node.parentId, node.promptName, node.promptName, node.id, node.promptName),
|
|
576
|
+
deps.db
|
|
577
|
+
.prepare(`INSERT INTO idempotency_keys (
|
|
578
|
+
actor_id, operation, idempotency_key, resource_id, created_at
|
|
579
|
+
) SELECT ?, 'node.update', ?, ?, ?
|
|
580
|
+
WHERE EXISTS (
|
|
581
|
+
SELECT 1 FROM nodes
|
|
582
|
+
WHERE id = ? AND parent_id IS ? AND title = ? AND description IS ?
|
|
583
|
+
AND prompt_name IS ? AND updated_at = ?
|
|
584
|
+
)`)
|
|
585
|
+
.bind(input.actorId, input.idempotencyKey, node.id, node.updatedAt, node.id, node.parentId, node.title, node.description, node.promptName, node.updatedAt),
|
|
586
|
+
deps.db
|
|
587
|
+
.prepare(`INSERT INTO audit_events (
|
|
588
|
+
id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
|
|
589
|
+
) SELECT ?, ?, 'node.update', 'node', ?, ?, ?
|
|
590
|
+
WHERE EXISTS (
|
|
591
|
+
SELECT 1 FROM idempotency_keys
|
|
592
|
+
WHERE actor_id = ? AND operation = 'node.update'
|
|
593
|
+
AND idempotency_key = ? AND resource_id = ?
|
|
594
|
+
)`)
|
|
595
|
+
.bind(input.auditId, input.actorId, node.id, JSON.stringify({
|
|
596
|
+
parentId: node.parentId,
|
|
597
|
+
title: node.title,
|
|
598
|
+
}), node.updatedAt, input.actorId, input.idempotencyKey, node.id),
|
|
599
|
+
]);
|
|
600
|
+
}
|
|
601
|
+
catch (error) {
|
|
602
|
+
const replayed = await deps.db
|
|
603
|
+
.prepare(`SELECT resource_id FROM idempotency_keys
|
|
604
|
+
WHERE actor_id = ? AND operation = 'node.update' AND idempotency_key = ?`)
|
|
605
|
+
.bind(input.actorId, input.idempotencyKey)
|
|
606
|
+
.first();
|
|
607
|
+
if (!replayed)
|
|
608
|
+
throw error;
|
|
609
|
+
}
|
|
610
|
+
const replayed = await deps.db
|
|
611
|
+
.prepare(`SELECT resource_id FROM idempotency_keys
|
|
612
|
+
WHERE actor_id = ? AND operation = 'node.update' AND idempotency_key = ?`)
|
|
613
|
+
.bind(input.actorId, input.idempotencyKey)
|
|
614
|
+
.first();
|
|
615
|
+
if (replayed) {
|
|
616
|
+
const row = await deps.db
|
|
617
|
+
.prepare(`SELECT ${nodeColumns} FROM nodes n WHERE n.id = ?`)
|
|
618
|
+
.bind(replayed.resource_id)
|
|
619
|
+
.first();
|
|
620
|
+
if (row)
|
|
621
|
+
return mapNode(row);
|
|
622
|
+
}
|
|
623
|
+
if (node.parentId !== null) {
|
|
624
|
+
const cycle = await deps.db
|
|
625
|
+
.prepare(`WITH RECURSIVE descendants(id) AS (
|
|
626
|
+
SELECT id FROM nodes WHERE id = ?
|
|
627
|
+
UNION ALL
|
|
628
|
+
SELECT child.id
|
|
629
|
+
FROM nodes child
|
|
630
|
+
JOIN descendants parent ON child.parent_id = parent.id
|
|
631
|
+
)
|
|
632
|
+
SELECT 1 AS found FROM descendants WHERE id = ? LIMIT 1`)
|
|
633
|
+
.bind(node.id, node.parentId)
|
|
634
|
+
.first();
|
|
635
|
+
if (cycle?.found === 1)
|
|
636
|
+
return "cycle";
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* ⚠️ **Which of the two refusals it was, asked only once the write has already failed.** The
|
|
640
|
+
* condition on the statement is what makes the guard hold; this read is what makes the answer
|
|
641
|
+
* sayable. Doing it the other way round — deciding here and writing unguarded — is the shape
|
|
642
|
+
* that loses the race it was written for.
|
|
643
|
+
*
|
|
644
|
+
* ⚠️ It is asked of BOTH tables, because the name is one catalogue across the two (#775). A
|
|
645
|
+
* check against `nodes` alone answers "free" for a name a published flow holds, and the write
|
|
646
|
+
* would then keep failing with a message that says the node was changed by another editor.
|
|
647
|
+
*/
|
|
648
|
+
if (node.promptName !== null) {
|
|
649
|
+
const taken = await deps.db
|
|
650
|
+
.prepare(`SELECT 1 AS found FROM nodes WHERE prompt_name = ? AND id <> ?
|
|
651
|
+
UNION ALL
|
|
652
|
+
SELECT 1 AS found FROM flows WHERE prompt_name = ?
|
|
653
|
+
LIMIT 1`)
|
|
654
|
+
.bind(node.promptName, node.id, node.promptName)
|
|
655
|
+
.first();
|
|
656
|
+
if (taken?.found === 1)
|
|
657
|
+
return "prompt_name_taken";
|
|
658
|
+
}
|
|
659
|
+
return "conflict";
|
|
660
|
+
},
|
|
661
|
+
async promptNameHolder(actor, name) {
|
|
662
|
+
// One question, one implementation, shared with the flow repository — see `db-prompts.ts`.
|
|
663
|
+
return await promptNameHolder(deps, actor, name);
|
|
664
|
+
},
|
|
665
|
+
async archiveNode(input) {
|
|
666
|
+
const archiving = input.archivedAt !== null;
|
|
667
|
+
/**
|
|
668
|
+
* ⚠️ **The operation a node falls with, read BEFORE anything moves** (D71, #733).
|
|
669
|
+
*
|
|
670
|
+
* Archiving takes the whole subtree, so a restore needs to know which nodes fell TOGETHER —
|
|
671
|
+
* anything already archived before keeps its own older operation and stays archived. On the
|
|
672
|
+
* way in that is the new id; on the way back it is the one the root already carries, and it
|
|
673
|
+
* has to be read here because the first statement below clears it.
|
|
674
|
+
*
|
|
675
|
+
* ⚠️ `NULL` is not a group. A node archived before this migration carries `NULL`, and
|
|
676
|
+
* restoring it must bring back that node alone — matching on `NULL` would revive every node
|
|
677
|
+
* ever archived before the migration ran.
|
|
678
|
+
*/
|
|
679
|
+
const group = archiving
|
|
680
|
+
? input.operationId
|
|
681
|
+
: ((await deps.db
|
|
682
|
+
.prepare("SELECT archived_with FROM nodes WHERE id = ?")
|
|
683
|
+
.bind(input.nodeId)
|
|
684
|
+
.first())?.archived_with ?? null);
|
|
685
|
+
const subtree = `WITH RECURSIVE tree(id) AS (
|
|
686
|
+
SELECT id FROM nodes WHERE id = ?
|
|
687
|
+
UNION SELECT child.id FROM nodes child JOIN tree parent ON child.parent_id = parent.id
|
|
688
|
+
)`;
|
|
689
|
+
/**
|
|
690
|
+
* ⚠️ **Every cascading statement carries the same guard, not just the first** (#457). A
|
|
691
|
+
* `D1.batch` is one transaction, but a statement matching zero rows is not an error — so
|
|
692
|
+
* without `EXISTS` on the root the children would fall while the root stayed put, taken out
|
|
693
|
+
* by the `updated_at` guard on statement one, and the caller would be told about a conflict
|
|
694
|
+
* that had already half happened.
|
|
695
|
+
*/
|
|
696
|
+
const rootMoved = archiving
|
|
697
|
+
? "EXISTS (SELECT 1 FROM nodes WHERE id = ? AND archived_at = ? AND archived_with = ?)"
|
|
698
|
+
: "EXISTS (SELECT 1 FROM nodes WHERE id = ? AND archived_at IS NULL)";
|
|
699
|
+
const cascade = archiving
|
|
700
|
+
? [
|
|
701
|
+
// The subtree, minus what was already archived: those keep their own operation.
|
|
702
|
+
deps.db
|
|
703
|
+
.prepare(`${subtree}
|
|
704
|
+
UPDATE nodes SET archived_at = ?, archived_with = ?, updated_at = ?
|
|
705
|
+
WHERE id IN (SELECT id FROM tree) AND id != ? AND archived_at IS NULL
|
|
706
|
+
AND ${rootMoved}`)
|
|
707
|
+
.bind(input.nodeId, input.archivedAt, input.operationId, input.updatedAt, input.nodeId, input.nodeId, input.archivedAt, input.operationId),
|
|
708
|
+
// Flows hang in the same tree by `parent_id` and carry their own `archived_at`.
|
|
709
|
+
deps.db
|
|
710
|
+
.prepare(`${subtree}
|
|
711
|
+
UPDATE flows SET archived_at = ?, archived_with = ?, updated_at = ?
|
|
712
|
+
WHERE parent_id IN (SELECT id FROM tree) AND archived_at IS NULL
|
|
713
|
+
AND ${rootMoved}`)
|
|
714
|
+
.bind(input.nodeId, input.archivedAt, input.operationId, input.updatedAt, input.nodeId, input.archivedAt, input.operationId),
|
|
715
|
+
]
|
|
716
|
+
: group === null
|
|
717
|
+
? []
|
|
718
|
+
: [
|
|
719
|
+
/**
|
|
720
|
+
* ⚠️ **The group AND the subtree, not the group alone** (found in review of #738).
|
|
721
|
+
* Matching on the operation by itself made restoring a leaf bring back its former
|
|
722
|
+
* parent and every sibling that fell with it: one card pulled out of a board's
|
|
723
|
+
* archive column put the whole card tree back. The walk starts at the node being
|
|
724
|
+
* restored, so a leaf restores itself and a folder restores what hangs under it.
|
|
725
|
+
*/
|
|
726
|
+
deps.db
|
|
727
|
+
.prepare(`${subtree}
|
|
728
|
+
UPDATE nodes SET archived_at = NULL, archived_with = NULL, updated_at = ?
|
|
729
|
+
WHERE id IN (SELECT id FROM tree) AND id != ? AND archived_with = ?
|
|
730
|
+
AND ${rootMoved}`)
|
|
731
|
+
.bind(input.nodeId, input.updatedAt, input.nodeId, group, input.nodeId),
|
|
732
|
+
deps.db
|
|
733
|
+
.prepare(`${subtree}
|
|
734
|
+
UPDATE flows SET archived_at = NULL, archived_with = NULL, updated_at = ?
|
|
735
|
+
WHERE parent_id IN (SELECT id FROM tree) AND archived_with = ?
|
|
736
|
+
AND ${rootMoved}`)
|
|
737
|
+
.bind(input.nodeId, input.updatedAt, group, input.nodeId),
|
|
738
|
+
];
|
|
739
|
+
// How many rows this operation moves besides the root, read before it moves them: the journal
|
|
740
|
+
// line is written inside the same batch and cannot count what it is doing.
|
|
741
|
+
const cascadeSize = archiving
|
|
742
|
+
? ((await deps.db
|
|
743
|
+
.prepare(`WITH RECURSIVE tree(id) AS (
|
|
744
|
+
SELECT id FROM nodes WHERE id = ?
|
|
745
|
+
UNION SELECT child.id FROM nodes child JOIN tree parent ON child.parent_id = parent.id
|
|
746
|
+
)
|
|
747
|
+
SELECT (SELECT COUNT(*) FROM nodes WHERE id IN (SELECT id FROM tree) AND id != ?
|
|
748
|
+
AND archived_at IS NULL)
|
|
749
|
+
+ (SELECT COUNT(*) FROM flows WHERE parent_id IN (SELECT id FROM tree)
|
|
750
|
+
AND archived_at IS NULL) AS total`)
|
|
751
|
+
.bind(input.nodeId, input.nodeId)
|
|
752
|
+
.first())?.total ?? 0)
|
|
753
|
+
: group === null
|
|
754
|
+
? 0
|
|
755
|
+
: ((await deps.db
|
|
756
|
+
.prepare(`WITH RECURSIVE tree(id) AS (
|
|
757
|
+
SELECT id FROM nodes WHERE id = ?
|
|
758
|
+
UNION SELECT child.id FROM nodes child JOIN tree parent ON child.parent_id = parent.id
|
|
759
|
+
)
|
|
760
|
+
SELECT (SELECT COUNT(*) FROM nodes WHERE id IN (SELECT id FROM tree) AND id != ?
|
|
761
|
+
AND archived_with = ?)
|
|
762
|
+
+ (SELECT COUNT(*) FROM flows WHERE parent_id IN (SELECT id FROM tree)
|
|
763
|
+
AND archived_with = ?) AS total`)
|
|
764
|
+
.bind(input.nodeId, input.nodeId, group, group)
|
|
765
|
+
.first())?.total ?? 0);
|
|
766
|
+
try {
|
|
767
|
+
await deps.db.batch([
|
|
768
|
+
deps.db
|
|
769
|
+
.prepare(`UPDATE nodes
|
|
770
|
+
SET archived_at = ?, archived_with = ?, updated_at = ?
|
|
771
|
+
WHERE id = ? AND updated_at = ?`)
|
|
772
|
+
.bind(input.archivedAt, archiving ? input.operationId : null, input.updatedAt, input.nodeId, input.baseUpdatedAt),
|
|
773
|
+
...cascade,
|
|
774
|
+
deps.db
|
|
775
|
+
.prepare(`INSERT INTO idempotency_keys (
|
|
776
|
+
actor_id, operation, idempotency_key, resource_id, created_at
|
|
777
|
+
) SELECT ?, 'node.archive', ?, ?, ?
|
|
778
|
+
WHERE EXISTS (
|
|
779
|
+
SELECT 1 FROM nodes
|
|
780
|
+
WHERE id = ? AND archived_at IS ? AND updated_at = ?
|
|
781
|
+
)`)
|
|
782
|
+
.bind(input.actorId, input.idempotencyKey, input.nodeId, input.updatedAt, input.nodeId, input.archivedAt, input.updatedAt),
|
|
783
|
+
deps.db
|
|
784
|
+
.prepare(`INSERT INTO audit_events (
|
|
785
|
+
id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
|
|
786
|
+
) SELECT ?, ?, 'node.archive', 'node', ?, ?, ?
|
|
787
|
+
WHERE EXISTS (
|
|
788
|
+
SELECT 1 FROM idempotency_keys
|
|
789
|
+
WHERE actor_id = ? AND operation = 'node.archive'
|
|
790
|
+
AND idempotency_key = ? AND resource_id = ?
|
|
791
|
+
)`)
|
|
792
|
+
.bind(input.auditId, input.actorId, input.nodeId,
|
|
793
|
+
/**
|
|
794
|
+
* ⚠️ **The operation and the SIZE of what moved** (D71, #733). One `node.archive` now
|
|
795
|
+
* moves a whole subtree, and the change journal (#747) reads this table: without the
|
|
796
|
+
* count, archiving a folder makes N documents disappear from every list behind a
|
|
797
|
+
* single line that names only the folder.
|
|
798
|
+
*
|
|
799
|
+
* ⚠️ What this row does NOT carry is WHICH nodes fell. After a restore the column is
|
|
800
|
+
* cleared, so that list is gone; if it is ever needed, it needs its own row per node
|
|
801
|
+
* and that is a decision, not a detail.
|
|
802
|
+
*/
|
|
803
|
+
JSON.stringify({
|
|
804
|
+
archived: input.archivedAt !== null,
|
|
805
|
+
operation: group,
|
|
806
|
+
cascaded: cascadeSize,
|
|
807
|
+
}), input.updatedAt, input.actorId, input.idempotencyKey, input.nodeId),
|
|
808
|
+
]);
|
|
809
|
+
}
|
|
810
|
+
catch (error) {
|
|
811
|
+
const replayed = await deps.db
|
|
812
|
+
.prepare(`SELECT resource_id FROM idempotency_keys
|
|
813
|
+
WHERE actor_id = ? AND operation = 'node.archive' AND idempotency_key = ?`)
|
|
814
|
+
.bind(input.actorId, input.idempotencyKey)
|
|
815
|
+
.first();
|
|
816
|
+
if (!replayed)
|
|
817
|
+
throw error;
|
|
818
|
+
}
|
|
819
|
+
const replayed = await deps.db
|
|
820
|
+
.prepare(`SELECT resource_id FROM idempotency_keys
|
|
821
|
+
WHERE actor_id = ? AND operation = 'node.archive' AND idempotency_key = ?`)
|
|
822
|
+
.bind(input.actorId, input.idempotencyKey)
|
|
823
|
+
.first();
|
|
824
|
+
if (!replayed)
|
|
825
|
+
return "conflict";
|
|
826
|
+
const row = await deps.db
|
|
827
|
+
.prepare(`SELECT ${nodeColumns} FROM nodes n WHERE n.id = ?`)
|
|
828
|
+
.bind(replayed.resource_id)
|
|
829
|
+
.first();
|
|
830
|
+
return row ? mapNode(row) : "conflict";
|
|
831
|
+
},
|
|
832
|
+
async parentArchivedAt(nodeId) {
|
|
833
|
+
// ⚠️ **No visibility predicate, and that is the whole point of this query** (found in review
|
|
834
|
+
// of #738). A grant can sit on a single node (ADR-0004 §2), so whoever may restore a child may
|
|
835
|
+
// well not see the folder above it. Asked through `getVisible`, that folder answers `null`,
|
|
836
|
+
// the guard reads "not archived", and a live node ends up under an archived one — where the
|
|
837
|
+
// purge takes it unnamed. It is the same lesson `countLiveChildren` carried before D71
|
|
838
|
+
// replaced it, and the same mistake was made again in its place.
|
|
839
|
+
//
|
|
840
|
+
// It leaks one timestamp about a node the caller cannot open. The alternative leaks the node
|
|
841
|
+
// itself, permanently.
|
|
842
|
+
const row = await deps.db
|
|
843
|
+
.prepare("SELECT archived_at, kind FROM nodes WHERE id = ?")
|
|
844
|
+
.bind(nodeId)
|
|
845
|
+
.first();
|
|
846
|
+
return row ? { archivedAt: row.archived_at, kind: row.kind } : null;
|
|
847
|
+
},
|
|
848
|
+
async versionIdsInSubtree(nodeId) {
|
|
849
|
+
// ⚠️ No visibility predicate and no archived filter, for the reason `hasAnyChild` gives: this
|
|
850
|
+
// answers what the INDEX has to be told about, not what somebody may look at. A node whose
|
|
851
|
+
// vectors stay behind pays for itself out of everybody else's search results (#348).
|
|
852
|
+
const rows = await deps.db
|
|
853
|
+
.prepare(`WITH RECURSIVE tree(id) AS (
|
|
854
|
+
SELECT id FROM nodes WHERE id = ?
|
|
855
|
+
UNION SELECT child.id FROM nodes child JOIN tree parent ON child.parent_id = parent.id
|
|
856
|
+
)
|
|
857
|
+
SELECT n.current_version_id AS version_id FROM nodes n
|
|
858
|
+
JOIN tree ON tree.id = n.id
|
|
859
|
+
WHERE n.current_version_id IS NOT NULL`)
|
|
860
|
+
.bind(nodeId)
|
|
861
|
+
.all();
|
|
862
|
+
return (rows.results ?? []).map((row) => row.version_id);
|
|
863
|
+
},
|
|
864
|
+
async hasAnyChild(nodeId) {
|
|
865
|
+
// ⚠️ Without a visibility predicate and without an archived filter, both on purpose: this
|
|
866
|
+
// answers whether the row can be deleted at all, not what somebody may look at (#457).
|
|
867
|
+
//
|
|
868
|
+
// ⚠️ BOTH tables, and that is not defensive breadth: knowledge and flows share ONE tree
|
|
869
|
+
// (ADR-0004), `flows.parent_id` references `nodes(id)` since migration 0002, and a folder whose
|
|
870
|
+
// only content is a flow answered "empty" while it was not. Purging it left the flow with a
|
|
871
|
+
// parent that no longer exists — invisible in the tree, unreachable, unmovable.
|
|
872
|
+
const row = await deps.db
|
|
873
|
+
.prepare(`SELECT 1 AS found FROM nodes WHERE parent_id = ?
|
|
874
|
+
UNION ALL
|
|
875
|
+
SELECT 1 AS found FROM flows WHERE parent_id = ?
|
|
876
|
+
LIMIT 1`)
|
|
877
|
+
.bind(nodeId, nodeId)
|
|
878
|
+
.first();
|
|
879
|
+
return row !== null;
|
|
880
|
+
},
|
|
881
|
+
// The names this node's vectors carry in the external index. They are read BEFORE the row falls,
|
|
882
|
+
// because `node_vectors` is the only thing that knows them — and once it is gone, an orphan in
|
|
883
|
+
// Vectorize can never be named again (#457).
|
|
884
|
+
async listVectorKeys(nodeId) {
|
|
885
|
+
const rows = await deps.db
|
|
886
|
+
.prepare("SELECT chunk_key FROM node_vectors WHERE node_id = ?")
|
|
887
|
+
.bind(nodeId)
|
|
888
|
+
.all();
|
|
889
|
+
return (rows.results ?? []).map((row) => row.chunk_key);
|
|
890
|
+
},
|
|
891
|
+
async countInboundLinks(nodeId) {
|
|
892
|
+
const row = await deps.db
|
|
893
|
+
.prepare(`WITH RECURSIVE tree(id) AS (
|
|
894
|
+
SELECT id FROM nodes WHERE id = ?
|
|
895
|
+
UNION ALL SELECT child.id FROM nodes child JOIN tree parent ON child.parent_id = parent.id
|
|
896
|
+
) SELECT COUNT(*) AS n FROM node_links
|
|
897
|
+
WHERE target_node_id IN (SELECT id FROM tree)
|
|
898
|
+
AND source_node_id NOT IN (SELECT id FROM tree)`)
|
|
899
|
+
.bind(nodeId)
|
|
900
|
+
.first();
|
|
901
|
+
return row?.n ?? 0;
|
|
902
|
+
},
|
|
903
|
+
async inspectPurgeTree(nodeId) {
|
|
904
|
+
const tree = `WITH RECURSIVE tree(id) AS (
|
|
905
|
+
SELECT id FROM nodes WHERE id = ?
|
|
906
|
+
UNION ALL SELECT child.id FROM nodes child JOIN tree parent ON child.parent_id = parent.id
|
|
907
|
+
)`;
|
|
908
|
+
const nodes = await deps.db
|
|
909
|
+
.prepare(`${tree} SELECT n.id, n.kind, n.title FROM nodes n JOIN tree ON tree.id = n.id`)
|
|
910
|
+
.bind(nodeId)
|
|
911
|
+
.all();
|
|
912
|
+
const nodeIds = (nodes.results ?? []).map((row) => row.id);
|
|
913
|
+
const flows = await deps.db
|
|
914
|
+
.prepare(`${tree} SELECT f.id, f.title FROM flows f WHERE f.parent_id IN (SELECT id FROM tree)`)
|
|
915
|
+
.bind(nodeId)
|
|
916
|
+
.all();
|
|
917
|
+
const keys = await deps.db
|
|
918
|
+
.prepare(`${tree} SELECT v.content_key FROM node_versions v WHERE v.node_id IN (SELECT id FROM tree)`)
|
|
919
|
+
.bind(nodeId)
|
|
920
|
+
.all();
|
|
921
|
+
const vectors = await deps.db
|
|
922
|
+
.prepare(`${tree} SELECT v.node_id, v.chunk_key FROM node_vectors v WHERE v.node_id IN (SELECT id FROM tree)`)
|
|
923
|
+
.bind(nodeId)
|
|
924
|
+
.all();
|
|
925
|
+
const grouped = new Map();
|
|
926
|
+
for (const row of vectors.results ?? []) {
|
|
927
|
+
const current = grouped.get(row.node_id) ?? [];
|
|
928
|
+
current.push(row.chunk_key);
|
|
929
|
+
grouped.set(row.node_id, current);
|
|
930
|
+
}
|
|
931
|
+
const counts = { flow: flows.results?.length ?? 0 };
|
|
932
|
+
for (const row of nodes.results ?? [])
|
|
933
|
+
counts[row.kind] = (counts[row.kind] ?? 0) + 1;
|
|
934
|
+
return {
|
|
935
|
+
nodeIds,
|
|
936
|
+
// The names the preview shows, capped there rather than here: a caller that wants the whole
|
|
937
|
+
// tree has `nodeIds`, and the cap belongs where the sentence is written (#733).
|
|
938
|
+
/**
|
|
939
|
+
* ⚠️ **Flows FIRST, and the order is the finding rather than a preference** (second review
|
|
940
|
+
* of #738). The preview names the first fifty, so with flows appended a folder holding
|
|
941
|
+
* fifty documents and one flow named every document and hid the flow — the one thing the
|
|
942
|
+
* cascade had to be widened for, and the one a reader is least likely to expect in there.
|
|
943
|
+
*/
|
|
944
|
+
named: [
|
|
945
|
+
...(flows.results ?? []).map((row) => ({
|
|
946
|
+
id: row.id,
|
|
947
|
+
kind: "flow",
|
|
948
|
+
title: row.title,
|
|
949
|
+
})),
|
|
950
|
+
...(nodes.results ?? []).map((row) => ({
|
|
951
|
+
id: row.id,
|
|
952
|
+
kind: row.kind,
|
|
953
|
+
title: row.title,
|
|
954
|
+
})),
|
|
955
|
+
],
|
|
956
|
+
flowIds: (flows.results ?? []).map((row) => row.id),
|
|
957
|
+
contentKeys: [...new Set((keys.results ?? []).map((row) => row.content_key))],
|
|
958
|
+
vectorKeys: nodeIds.map((id) => ({ nodeId: id, keys: grouped.get(id) ?? [] })),
|
|
959
|
+
counts,
|
|
960
|
+
};
|
|
961
|
+
},
|
|
962
|
+
async findPurgeReplay(actorId, idempotencyKey) {
|
|
963
|
+
const row = await deps.db
|
|
964
|
+
.prepare(`SELECT title, content_keys_json FROM node_purge_receipts
|
|
965
|
+
WHERE actor_id = ? AND idempotency_key = ?`)
|
|
966
|
+
.bind(actorId, idempotencyKey)
|
|
967
|
+
.first();
|
|
968
|
+
return row ? { title: row.title, contentKeys: JSON.parse(row.content_keys_json) } : null;
|
|
969
|
+
},
|
|
970
|
+
async completePurgeReplay(actorId, idempotencyKey, completedAt) {
|
|
971
|
+
await deps.db
|
|
972
|
+
.prepare(`UPDATE node_purge_receipts SET completed_at = ?
|
|
973
|
+
WHERE actor_id = ? AND idempotency_key = ?`)
|
|
974
|
+
.bind(completedAt, actorId, idempotencyKey)
|
|
975
|
+
.run();
|
|
976
|
+
},
|
|
977
|
+
async purgeNode(input) {
|
|
978
|
+
// The keys BEFORE anything falls: after the batch there is no version row left to read them
|
|
979
|
+
// from, and an object nobody can name is an object nobody can delete.
|
|
980
|
+
const keys = await deps.db
|
|
981
|
+
.prepare(`WITH RECURSIVE tree(id) AS (
|
|
982
|
+
SELECT id FROM nodes WHERE id = ?
|
|
983
|
+
UNION ALL SELECT child.id FROM nodes child JOIN tree parent ON child.parent_id = parent.id
|
|
984
|
+
) SELECT content_key FROM node_versions WHERE node_id IN (SELECT id FROM tree)`)
|
|
985
|
+
.bind(input.nodeId)
|
|
986
|
+
.all();
|
|
987
|
+
// ⚠️ The guard belongs on EVERY statement, not only on the last one (found in review). A
|
|
988
|
+
// `D1.batch` is one implicit transaction, but a final statement that changes ZERO rows is not
|
|
989
|
+
// an error — the transaction commits. With the condition only at the end, a restore between
|
|
990
|
+
// the service's check and this batch deleted every version, grant, link and index row of a
|
|
991
|
+
// node that was alive again, left its row standing, and answered `409 update_conflict`: the
|
|
992
|
+
// reader was told nothing had happened while the content was already gone. The audit row
|
|
993
|
+
// carried the same condition, so it was not even booked.
|
|
994
|
+
const alive = "EXISTS (SELECT 1 FROM nodes n WHERE n.id = ? AND n.archived_at IS NOT NULL)";
|
|
995
|
+
const tree = `WITH RECURSIVE tree(id) AS (
|
|
996
|
+
SELECT id FROM nodes WHERE id = ?
|
|
997
|
+
UNION ALL SELECT child.id FROM nodes child JOIN tree parent ON child.parent_id = parent.id
|
|
998
|
+
)`;
|
|
999
|
+
const treeFlows = `${tree}, tree_flows(id) AS (
|
|
1000
|
+
SELECT id FROM flows WHERE parent_id IN (SELECT id FROM tree)
|
|
1001
|
+
)`;
|
|
1002
|
+
const deleted = await deps.db.batch([
|
|
1003
|
+
// ⚠️ FIRST, and reading the title out of the row that falls at the end of this batch. Its
|
|
1004
|
+
// `WHERE EXISTS` is what makes it honest: no row, no entry — a purge that hit nothing does
|
|
1005
|
+
// not book one.
|
|
1006
|
+
//
|
|
1007
|
+
// ⚠️ **`parentId` travels beside the title, and it is what makes this row READABLE
|
|
1008
|
+
// afterwards** (#645). Visibility everywhere else walks `nodes` from the row itself, and
|
|
1009
|
+
// after this batch that row is gone — so until now the event was unreachable for everybody,
|
|
1010
|
+
// admin included. The surviving FOLDER answers instead. It is written here rather than
|
|
1011
|
+
// derived later for the same reason the title is: after the delete there is nothing left to
|
|
1012
|
+
// look either of them up from (`destructive.md`).
|
|
1013
|
+
deps.db
|
|
1014
|
+
.prepare(`INSERT INTO audit_events (
|
|
1015
|
+
id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
|
|
1016
|
+
) SELECT ?, ?, 'node.purge', 'node', n.id,
|
|
1017
|
+
json_patch(?, json_object('title', n.title, 'kind', n.kind, 'parentId', n.parent_id)), ?
|
|
1018
|
+
FROM nodes n WHERE n.id = ? AND n.archived_at IS NOT NULL`)
|
|
1019
|
+
.bind(input.auditId, input.actorId, JSON.stringify(input.metadata), input.occurredAt, input.nodeId),
|
|
1020
|
+
deps.db
|
|
1021
|
+
.prepare(`INSERT INTO node_purge_receipts (
|
|
1022
|
+
actor_id, idempotency_key, node_id, title, content_keys_json, completed_at
|
|
1023
|
+
) SELECT ?, ?, n.id, n.title, ?, NULL FROM nodes n
|
|
1024
|
+
WHERE n.id = ? AND n.archived_at IS NOT NULL`)
|
|
1025
|
+
.bind(input.actorId, input.idempotencyKey, JSON.stringify([...new Set((keys.results ?? []).map((row) => row.content_key))]), input.nodeId),
|
|
1026
|
+
// The derived indexes go with their source. ⚠️ A hit on a deleted document is not a blemish
|
|
1027
|
+
// but a leak: the title stands in the result and the content is gone.
|
|
1028
|
+
deps.db
|
|
1029
|
+
.prepare(`${tree} DELETE FROM node_fts WHERE node_id IN (SELECT id FROM tree) AND ${alive}`)
|
|
1030
|
+
.bind(input.nodeId, input.nodeId),
|
|
1031
|
+
deps.db
|
|
1032
|
+
.prepare(`${tree} DELETE FROM node_vectors WHERE node_id IN (SELECT id FROM tree) AND ${alive}`)
|
|
1033
|
+
.bind(input.nodeId, input.nodeId),
|
|
1034
|
+
deps.db
|
|
1035
|
+
.prepare(`${tree} DELETE FROM node_index_state
|
|
1036
|
+
WHERE version_id IN (SELECT id FROM node_versions WHERE node_id IN (SELECT id FROM tree)) AND ${alive}`)
|
|
1037
|
+
.bind(input.nodeId, input.nodeId),
|
|
1038
|
+
// Both directions. Outgoing links belong to this node; incoming ones would otherwise point
|
|
1039
|
+
// at a row that is gone, and a foreign key would refuse the delete anyway.
|
|
1040
|
+
deps.db
|
|
1041
|
+
.prepare(`${tree} DELETE FROM node_links
|
|
1042
|
+
WHERE (source_node_id IN (SELECT id FROM tree) OR target_node_id IN (SELECT id FROM tree)) AND ${alive}`)
|
|
1043
|
+
.bind(input.nodeId, input.nodeId),
|
|
1044
|
+
// A permission on a node that does not exist is a dangling permission.
|
|
1045
|
+
deps.db
|
|
1046
|
+
.prepare(`${tree} DELETE FROM node_grants WHERE node_id IN (SELECT id FROM tree) AND ${alive}`)
|
|
1047
|
+
.bind(input.nodeId, input.nodeId),
|
|
1048
|
+
deps.db
|
|
1049
|
+
.prepare(`${treeFlows} UPDATE flows SET published_version_id = NULL
|
|
1050
|
+
WHERE id NOT IN (SELECT id FROM tree_flows) AND EXISTS (
|
|
1051
|
+
SELECT 1 FROM flow_versions fv, json_tree(fv.graph_json) graph
|
|
1052
|
+
WHERE fv.id IN (flows.current_version_id, flows.published_version_id)
|
|
1053
|
+
AND ((graph.key = 'resourceId' AND graph.value IN (SELECT id FROM tree))
|
|
1054
|
+
OR (graph.key = 'flowId' AND graph.value IN (SELECT id FROM tree_flows)))
|
|
1055
|
+
) AND ${alive}`)
|
|
1056
|
+
.bind(input.nodeId, input.nodeId),
|
|
1057
|
+
deps.db
|
|
1058
|
+
.prepare(`${treeFlows} DELETE FROM flow_run_steps
|
|
1059
|
+
WHERE run_id IN (SELECT id FROM flow_runs WHERE flow_id IN (SELECT id FROM tree_flows)) AND ${alive}`)
|
|
1060
|
+
.bind(input.nodeId, input.nodeId),
|
|
1061
|
+
deps.db
|
|
1062
|
+
.prepare(`${treeFlows} DELETE FROM flow_runs WHERE flow_id IN (SELECT id FROM tree_flows) AND ${alive}`)
|
|
1063
|
+
.bind(input.nodeId, input.nodeId),
|
|
1064
|
+
deps.db
|
|
1065
|
+
.prepare(`${treeFlows} DELETE FROM flow_versions WHERE flow_id IN (SELECT id FROM tree_flows) AND ${alive}`)
|
|
1066
|
+
.bind(input.nodeId, input.nodeId),
|
|
1067
|
+
deps.db
|
|
1068
|
+
.prepare(`${treeFlows} DELETE FROM idempotency_keys WHERE resource_id IN (SELECT id FROM tree_flows) AND ${alive}`)
|
|
1069
|
+
.bind(input.nodeId, input.nodeId),
|
|
1070
|
+
deps.db
|
|
1071
|
+
.prepare(`${treeFlows} DELETE FROM flows WHERE id IN (SELECT id FROM tree_flows) AND ${alive}`)
|
|
1072
|
+
.bind(input.nodeId, input.nodeId),
|
|
1073
|
+
deps.db
|
|
1074
|
+
.prepare(`${tree} DELETE FROM node_versions WHERE node_id IN (SELECT id FROM tree) AND ${alive}`)
|
|
1075
|
+
.bind(input.nodeId, input.nodeId),
|
|
1076
|
+
// ⚠️ The replay keys of every earlier write to this node. Left behind, a later write reusing
|
|
1077
|
+
// one of them would be answered with a resource id that no longer resolves.
|
|
1078
|
+
deps.db
|
|
1079
|
+
.prepare(`${tree} DELETE FROM idempotency_keys WHERE resource_id IN (SELECT id FROM tree) AND ${alive}`)
|
|
1080
|
+
.bind(input.nodeId, input.nodeId),
|
|
1081
|
+
// LAST — and its count is what the caller reads as the verdict.
|
|
1082
|
+
deps.db
|
|
1083
|
+
.prepare(`${tree} DELETE FROM nodes WHERE id IN (SELECT id FROM tree) AND ${alive}`)
|
|
1084
|
+
.bind(input.nodeId, input.nodeId),
|
|
1085
|
+
]);
|
|
1086
|
+
const removed = deleted.at(-1)?.meta?.changes ?? 0;
|
|
1087
|
+
if (removed === 0)
|
|
1088
|
+
return "missing";
|
|
1089
|
+
return { contentKeys: [...new Set((keys.results ?? []).map((row) => row.content_key))] };
|
|
1090
|
+
},
|
|
1091
|
+
async appendVersion(input) {
|
|
1092
|
+
const version = input.version;
|
|
1093
|
+
await deps.db.batch([
|
|
1094
|
+
deps.db
|
|
1095
|
+
.prepare(`INSERT INTO node_versions (
|
|
1096
|
+
id, node_id, sequence, content_key, media_type, content_hash, size, segment,
|
|
1097
|
+
created_by, created_at
|
|
1098
|
+
) SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
|
1099
|
+
FROM nodes
|
|
1100
|
+
WHERE id = ? AND current_version_id IS ?`)
|
|
1101
|
+
.bind(version.id, version.nodeId, version.sequence, version.contentKey, version.mediaType, version.contentHash, version.size, version.segment, version.createdBy, version.createdAt, version.nodeId, input.baseVersionId),
|
|
1102
|
+
deps.db
|
|
1103
|
+
.prepare(`UPDATE nodes
|
|
1104
|
+
SET current_version_id = ?, updated_at = ?
|
|
1105
|
+
WHERE id = ? AND EXISTS (SELECT 1 FROM node_versions WHERE id = ?)`)
|
|
1106
|
+
.bind(version.id, version.createdAt, version.nodeId, version.id),
|
|
1107
|
+
deps.db
|
|
1108
|
+
.prepare(`INSERT INTO idempotency_keys (
|
|
1109
|
+
actor_id, operation, idempotency_key, resource_id, created_at
|
|
1110
|
+
) SELECT ?, 'node.save', ?, ?, ?
|
|
1111
|
+
WHERE EXISTS (SELECT 1 FROM node_versions WHERE id = ?)`)
|
|
1112
|
+
.bind(input.actorId, input.idempotencyKey, version.id, version.createdAt, version.id),
|
|
1113
|
+
deps.db
|
|
1114
|
+
.prepare(`INSERT INTO node_index_state (
|
|
1115
|
+
version_id, status, attempt_count, last_error, updated_at
|
|
1116
|
+
) SELECT ?, 'pending', 0, NULL, ?
|
|
1117
|
+
WHERE EXISTS (SELECT 1 FROM node_versions WHERE id = ?)`)
|
|
1118
|
+
.bind(version.id, version.createdAt, version.id),
|
|
1119
|
+
deps.db
|
|
1120
|
+
.prepare(`INSERT INTO audit_events (
|
|
1121
|
+
id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
|
|
1122
|
+
) SELECT ?, ?, 'node.save', 'node', ?, ?, ?
|
|
1123
|
+
WHERE EXISTS (SELECT 1 FROM node_versions WHERE id = ?)`)
|
|
1124
|
+
.bind(input.auditId, input.actorId, version.nodeId, JSON.stringify({ versionId: version.id, sequence: version.sequence }), version.createdAt, version.id),
|
|
1125
|
+
]);
|
|
1126
|
+
const row = await deps.db
|
|
1127
|
+
.prepare("SELECT current_version_id FROM nodes WHERE id = ?")
|
|
1128
|
+
.bind(version.nodeId)
|
|
1129
|
+
.first();
|
|
1130
|
+
return row?.current_version_id === version.id ? "saved" : "conflict";
|
|
1131
|
+
},
|
|
1132
|
+
/**
|
|
1133
|
+
* One appended segment of a table (#40).
|
|
1134
|
+
*
|
|
1135
|
+
* ⚠️ The sequence is computed inside the INSERT, never read out and sent back in. Two appends
|
|
1136
|
+
* that arrive together would otherwise both read the same `MAX(sequence)` and the second would
|
|
1137
|
+
* lose to `UNIQUE (node_id, sequence)` — the very loss the ticket forbids.
|
|
1138
|
+
*
|
|
1139
|
+
* ⚠️ `current_version_id` only ever moves forward. It names the newest segment, and indexing
|
|
1140
|
+
* keys off it; letting a slower append pull it back would leave the index describing a table
|
|
1141
|
+
* that is missing its last rows.
|
|
1142
|
+
*/
|
|
1143
|
+
async appendTableVersion(input) {
|
|
1144
|
+
const version = input.version;
|
|
1145
|
+
const stored = async () => await deps.db
|
|
1146
|
+
.prepare(`SELECT ${versionColumns} FROM node_versions WHERE id = ?`)
|
|
1147
|
+
.bind(version.id)
|
|
1148
|
+
.first();
|
|
1149
|
+
try {
|
|
1150
|
+
await deps.db.batch([
|
|
1151
|
+
deps.db
|
|
1152
|
+
.prepare(`INSERT INTO node_versions (
|
|
1153
|
+
id, node_id, sequence, content_key, media_type, content_hash, size, segment,
|
|
1154
|
+
created_by, created_at
|
|
1155
|
+
) SELECT ?, ?, COALESCE(MAX(sequence), 0) + 1, ?, ?, ?, ?, ?, ?, ?
|
|
1156
|
+
FROM node_versions WHERE node_id = ?`)
|
|
1157
|
+
.bind(version.id, version.nodeId, version.contentKey, version.mediaType, version.contentHash, version.size, version.segment, version.createdBy, version.createdAt, version.nodeId),
|
|
1158
|
+
deps.db
|
|
1159
|
+
.prepare(`UPDATE nodes
|
|
1160
|
+
SET current_version_id = ?, updated_at = ?
|
|
1161
|
+
WHERE id = ?
|
|
1162
|
+
AND EXISTS (SELECT 1 FROM node_versions WHERE id = ?)
|
|
1163
|
+
AND (
|
|
1164
|
+
current_version_id IS NULL
|
|
1165
|
+
OR COALESCE((
|
|
1166
|
+
SELECT sequence FROM node_versions
|
|
1167
|
+
WHERE id = nodes.current_version_id
|
|
1168
|
+
), 0) < (SELECT sequence FROM node_versions WHERE id = ?)
|
|
1169
|
+
)`)
|
|
1170
|
+
.bind(version.id, version.createdAt, version.nodeId, version.id, version.id),
|
|
1171
|
+
deps.db
|
|
1172
|
+
.prepare(`INSERT INTO idempotency_keys (
|
|
1173
|
+
actor_id, operation, idempotency_key, resource_id, created_at
|
|
1174
|
+
) SELECT ?, 'node.append', ?, ?, ?
|
|
1175
|
+
WHERE EXISTS (SELECT 1 FROM node_versions WHERE id = ?)`)
|
|
1176
|
+
.bind(input.actorId, input.idempotencyKey, version.id, version.createdAt, version.id),
|
|
1177
|
+
deps.db
|
|
1178
|
+
.prepare(`INSERT INTO node_index_state (
|
|
1179
|
+
version_id, status, attempt_count, last_error, updated_at
|
|
1180
|
+
) SELECT ?, 'pending', 0, NULL, ?
|
|
1181
|
+
WHERE EXISTS (SELECT 1 FROM node_versions WHERE id = ?)`)
|
|
1182
|
+
.bind(version.id, version.createdAt, version.id),
|
|
1183
|
+
deps.db
|
|
1184
|
+
.prepare(`INSERT INTO audit_events (
|
|
1185
|
+
id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
|
|
1186
|
+
) SELECT ?, ?, 'node.append', 'node', ?, json_object(
|
|
1187
|
+
'versionId', ?, 'size', ?
|
|
1188
|
+
), ?
|
|
1189
|
+
WHERE EXISTS (SELECT 1 FROM node_versions WHERE id = ?)`)
|
|
1190
|
+
.bind(input.auditId, input.actorId, version.nodeId, version.id, version.size, version.createdAt, version.id),
|
|
1191
|
+
]);
|
|
1192
|
+
}
|
|
1193
|
+
catch (error) {
|
|
1194
|
+
// The same key twice at the same moment: the first write owns the row, and this call
|
|
1195
|
+
// answers with it rather than with a failure the caller cannot act on.
|
|
1196
|
+
const replayed = await deps.db
|
|
1197
|
+
.prepare(`SELECT resource_id FROM idempotency_keys
|
|
1198
|
+
WHERE actor_id = ? AND operation = 'node.append' AND idempotency_key = ?`)
|
|
1199
|
+
.bind(input.actorId, input.idempotencyKey)
|
|
1200
|
+
.first();
|
|
1201
|
+
const row = replayed
|
|
1202
|
+
? await deps.db
|
|
1203
|
+
.prepare(`SELECT ${versionColumns} FROM node_versions WHERE id = ?`)
|
|
1204
|
+
.bind(replayed.resource_id)
|
|
1205
|
+
.first()
|
|
1206
|
+
: null;
|
|
1207
|
+
if (row)
|
|
1208
|
+
return mapVersion(row);
|
|
1209
|
+
throw error;
|
|
1210
|
+
}
|
|
1211
|
+
const row = await stored();
|
|
1212
|
+
if (!row)
|
|
1213
|
+
throw new Error("Node table append did not store a version");
|
|
1214
|
+
return mapVersion(row);
|
|
1215
|
+
},
|
|
1216
|
+
/**
|
|
1217
|
+
* One snapshot — the whole readable state after a mutation — written only against the state it
|
|
1218
|
+
* replaces (#135, #285).
|
|
1219
|
+
*
|
|
1220
|
+
* ⚠️ The shape of `appendVersion`, not of the append above it: the base check sits inside the
|
|
1221
|
+
* INSERT's own WHERE, so a segment that landed between the service's read and this batch makes
|
|
1222
|
+
* the INSERT store nothing and every guarded statement after it a no-op. The service reads the
|
|
1223
|
+
* answer the same way `save` does — by asking whether the node now stands on the new version.
|
|
1224
|
+
*
|
|
1225
|
+
* The idempotency row carries the mutation's own operation, so a replayed update can never be
|
|
1226
|
+
* confused with a delete that reused the key, and the audit trail names what was done.
|
|
1227
|
+
*/
|
|
1228
|
+
async appendSnapshotVersion(input) {
|
|
1229
|
+
const version = input.version;
|
|
1230
|
+
await deps.db.batch([
|
|
1231
|
+
deps.db
|
|
1232
|
+
.prepare(`INSERT INTO node_versions (
|
|
1233
|
+
id, node_id, sequence, content_key, media_type, content_hash, size, segment,
|
|
1234
|
+
created_by, created_at
|
|
1235
|
+
) SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
|
1236
|
+
FROM nodes
|
|
1237
|
+
WHERE id = ? AND current_version_id IS ?`)
|
|
1238
|
+
.bind(version.id, version.nodeId, version.sequence, version.contentKey, version.mediaType, version.contentHash, version.size,
|
|
1239
|
+
// ⚠️ Taken from the version rather than written in as `'snapshot'`. A table's mutation
|
|
1240
|
+
// marks a snapshot, because reading a table starts at the newest one and everything
|
|
1241
|
+
// before it is history (#135). A snapshot version carries the whole document by
|
|
1242
|
+
// construction, so it marks nothing — the same `null` a document's version carries, and
|
|
1243
|
+
// for the same reason (#285).
|
|
1244
|
+
version.segment, version.createdBy, version.createdAt, version.nodeId, input.baseVersionId),
|
|
1245
|
+
deps.db
|
|
1246
|
+
.prepare(`UPDATE nodes
|
|
1247
|
+
SET current_version_id = ?, updated_at = ?
|
|
1248
|
+
WHERE id = ? AND EXISTS (SELECT 1 FROM node_versions WHERE id = ?)`)
|
|
1249
|
+
.bind(version.id, version.createdAt, version.nodeId, version.id),
|
|
1250
|
+
deps.db
|
|
1251
|
+
.prepare(`INSERT INTO idempotency_keys (
|
|
1252
|
+
actor_id, operation, idempotency_key, resource_id, created_at
|
|
1253
|
+
) SELECT ?, ?, ?, ?, ?
|
|
1254
|
+
WHERE EXISTS (SELECT 1 FROM node_versions WHERE id = ?)`)
|
|
1255
|
+
.bind(input.actorId, input.operation, input.idempotencyKey, version.id, version.createdAt, version.id),
|
|
1256
|
+
deps.db
|
|
1257
|
+
.prepare(`INSERT INTO node_index_state (
|
|
1258
|
+
version_id, status, attempt_count, last_error, updated_at
|
|
1259
|
+
) SELECT ?, 'pending', 0, NULL, ?
|
|
1260
|
+
WHERE EXISTS (SELECT 1 FROM node_versions WHERE id = ?)`)
|
|
1261
|
+
.bind(version.id, version.createdAt, version.id),
|
|
1262
|
+
deps.db
|
|
1263
|
+
.prepare(`INSERT INTO audit_events (
|
|
1264
|
+
id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
|
|
1265
|
+
) SELECT ?, ?, ?, 'node', ?, ?, ?
|
|
1266
|
+
WHERE EXISTS (SELECT 1 FROM node_versions WHERE id = ?)`)
|
|
1267
|
+
.bind(input.auditId, input.actorId, input.operation, version.nodeId, JSON.stringify({ versionId: version.id, ...input.metadata }), version.createdAt, version.id),
|
|
1268
|
+
]);
|
|
1269
|
+
const row = await deps.db
|
|
1270
|
+
.prepare("SELECT current_version_id FROM nodes WHERE id = ?")
|
|
1271
|
+
.bind(version.nodeId)
|
|
1272
|
+
.first();
|
|
1273
|
+
return row?.current_version_id === version.id ? "saved" : "conflict";
|
|
1274
|
+
},
|
|
1275
|
+
async listGrants(resourceId) {
|
|
1276
|
+
const result = await deps.db
|
|
1277
|
+
.prepare(`SELECT ${grantColumns} FROM node_grants
|
|
1278
|
+
WHERE node_id = ?
|
|
1279
|
+
ORDER BY principal_type, principal_id, verb`)
|
|
1280
|
+
.bind(resourceId)
|
|
1281
|
+
.all();
|
|
1282
|
+
return (result.results ?? []).map(mapGrant);
|
|
1283
|
+
},
|
|
1284
|
+
async listEffectiveAccess(resourceId) {
|
|
1285
|
+
// Owners and grants come from ONE statement, hence one D1 snapshot. Two parallel SELECTs are
|
|
1286
|
+
// not atomic: a grant mutation between their snapshots can produce a principal list that
|
|
1287
|
+
// never existed as a whole.
|
|
1288
|
+
const row = await deps.db
|
|
1289
|
+
.prepare(`WITH RECURSIVE ancestors(id, parent_id, owner_id) AS (
|
|
1290
|
+
SELECT id, parent_id, owner_id FROM nodes WHERE id = ?
|
|
1291
|
+
UNION
|
|
1292
|
+
SELECT parent.id, parent.parent_id, parent.owner_id
|
|
1293
|
+
FROM nodes parent
|
|
1294
|
+
JOIN ancestors child ON child.parent_id = parent.id
|
|
1295
|
+
)
|
|
1296
|
+
SELECT
|
|
1297
|
+
(SELECT json_group_array(owner_id)
|
|
1298
|
+
FROM (SELECT DISTINCT owner_id FROM ancestors ORDER BY owner_id)) AS owner_ids_json,
|
|
1299
|
+
(SELECT json_group_array(json_object(
|
|
1300
|
+
'id', id, 'resource_id', node_id, 'principal_type', principal_type,
|
|
1301
|
+
'principal_id', principal_id, 'verb', verb, 'expires_at', expires_at,
|
|
1302
|
+
'created_by', created_by, 'created_at', created_at
|
|
1303
|
+
))
|
|
1304
|
+
FROM (
|
|
1305
|
+
SELECT grant_row.id, grant_row.node_id, grant_row.principal_type,
|
|
1306
|
+
grant_row.principal_id, grant_row.verb, grant_row.expires_at,
|
|
1307
|
+
grant_row.created_by, grant_row.created_at
|
|
1308
|
+
FROM node_grants grant_row
|
|
1309
|
+
JOIN ancestors ON ancestors.id = grant_row.node_id
|
|
1310
|
+
WHERE ${grantInForce}
|
|
1311
|
+
ORDER BY grant_row.principal_type, grant_row.principal_id, grant_row.verb
|
|
1312
|
+
)) AS grants_json`)
|
|
1313
|
+
.bind(resourceId, deps.now().toISOString())
|
|
1314
|
+
.first();
|
|
1315
|
+
return {
|
|
1316
|
+
ownerIds: JSON.parse(row?.owner_ids_json ?? "[]"),
|
|
1317
|
+
items: JSON.parse(row?.grants_json ?? "[]").map(mapGrant),
|
|
1318
|
+
};
|
|
1319
|
+
},
|
|
1320
|
+
// ⚠️ `UNION`, never `UNION ALL`: a ring in `parent_id` must end the recursion rather than the
|
|
1321
|
+
// database, the same reason every other walk of this tree gives.
|
|
1322
|
+
async organizationExecuteReaches(nodeId, exceptGrantId) {
|
|
1323
|
+
const row = await deps.db
|
|
1324
|
+
.prepare(`WITH RECURSIVE ancestors(id, parent_id) AS (
|
|
1325
|
+
SELECT id, parent_id FROM nodes WHERE id = ?
|
|
1326
|
+
UNION
|
|
1327
|
+
SELECT parent.id, parent.parent_id
|
|
1328
|
+
FROM nodes parent
|
|
1329
|
+
JOIN ancestors child ON child.parent_id = parent.id
|
|
1330
|
+
)
|
|
1331
|
+
SELECT 1 AS reaches
|
|
1332
|
+
FROM node_grants grant_row
|
|
1333
|
+
JOIN ancestors ON ancestors.id = grant_row.node_id
|
|
1334
|
+
WHERE grant_row.id <> ?
|
|
1335
|
+
AND grant_row.principal_type = 'organization'
|
|
1336
|
+
AND grant_row.verb = 'execute'
|
|
1337
|
+
AND ${grantInForce}
|
|
1338
|
+
LIMIT 1`)
|
|
1339
|
+
.bind(nodeId, exceptGrantId, deps.now().toISOString())
|
|
1340
|
+
.first();
|
|
1341
|
+
return row?.reaches === 1;
|
|
1342
|
+
},
|
|
1343
|
+
async listLinksVisible(actor, nodeId) {
|
|
1344
|
+
const result = await deps.db
|
|
1345
|
+
.prepare(`${visibleCte}
|
|
1346
|
+
SELECT ${linkColumns}
|
|
1347
|
+
FROM node_links link
|
|
1348
|
+
JOIN allowed source_access ON source_access.id = link.source_node_id
|
|
1349
|
+
JOIN allowed target_access ON target_access.id = link.target_node_id
|
|
1350
|
+
WHERE link.source_node_id = ? OR link.target_node_id = ?
|
|
1351
|
+
ORDER BY link.created_at, link.id`)
|
|
1352
|
+
.bind(...readBindings(actor), nodeId, nodeId)
|
|
1353
|
+
.all();
|
|
1354
|
+
return (result.results ?? []).map(mapLink);
|
|
1355
|
+
},
|
|
1356
|
+
/**
|
|
1357
|
+
* The titles of the nodes among these that this actor may see (#41).
|
|
1358
|
+
*
|
|
1359
|
+
* ⚠️ The same `allowed` walk as every other read, joined rather than filtered afterwards, and
|
|
1360
|
+
* archived nodes are out. An unreachable or deleted target contributes no row at all — no
|
|
1361
|
+
* placeholder, no id echoed back — because a row is already the statement "something is here".
|
|
1362
|
+
*/
|
|
1363
|
+
async resolveVisibleTitles(actor, nodeIds) {
|
|
1364
|
+
const uniqueIds = [...new Set(nodeIds)].slice(0, 200);
|
|
1365
|
+
if (uniqueIds.length === 0)
|
|
1366
|
+
return [];
|
|
1367
|
+
const rows = [];
|
|
1368
|
+
for (let offset = 0; offset < uniqueIds.length; offset += 96) {
|
|
1369
|
+
const ids = uniqueIds.slice(offset, offset + 96);
|
|
1370
|
+
const placeholders = ids.map(() => "?").join(", ");
|
|
1371
|
+
const result = await deps.db
|
|
1372
|
+
.prepare(`${visibleCte}
|
|
1373
|
+
SELECT n.id, n.title
|
|
1374
|
+
FROM nodes n
|
|
1375
|
+
JOIN allowed ON allowed.id = n.id
|
|
1376
|
+
WHERE n.id IN (${placeholders}) AND n.archived_at IS NULL`)
|
|
1377
|
+
.bind(...readBindings(actor), ...ids)
|
|
1378
|
+
.all();
|
|
1379
|
+
rows.push(...(result.results ?? []));
|
|
1380
|
+
}
|
|
1381
|
+
return rows.map((row) => ({ nodeId: row.id, title: row.title }));
|
|
1382
|
+
},
|
|
1383
|
+
/**
|
|
1384
|
+
* The `text` links of one document, replaced wholesale by what the saved text says (#41).
|
|
1385
|
+
*
|
|
1386
|
+
* ⚠️ `origin = 'text'` in the delete, and nowhere else. Links made in the removed dialog are
|
|
1387
|
+
* `manual`; saving a document must not take away a relationship somebody entered before the
|
|
1388
|
+
* text became the place to write one.
|
|
1389
|
+
*
|
|
1390
|
+
* The whole set is written in one batch, so a save never leaves the graph half-rewritten. There
|
|
1391
|
+
* is no idempotency row: the operation is a replacement, and replaying it writes the same set.
|
|
1392
|
+
*/
|
|
1393
|
+
async replaceTextLinks(input) {
|
|
1394
|
+
await deps.db.batch([
|
|
1395
|
+
deps.db
|
|
1396
|
+
.prepare("DELETE FROM node_links WHERE source_node_id = ? AND origin = 'text'")
|
|
1397
|
+
.bind(input.sourceNodeId),
|
|
1398
|
+
...input.links.map((link) => deps.db
|
|
1399
|
+
.prepare(`INSERT INTO node_links (
|
|
1400
|
+
id, source_node_id, target_node_id, relation, origin, label, created_by, created_at
|
|
1401
|
+
) VALUES (?, ?, ?, 'references', 'text', NULL, ?, ?)
|
|
1402
|
+
ON CONFLICT (source_node_id, target_node_id, relation) DO NOTHING`)
|
|
1403
|
+
.bind(link.id, input.sourceNodeId, link.targetNodeId, input.actorId, input.occurredAt)),
|
|
1404
|
+
deps.db
|
|
1405
|
+
.prepare(`INSERT INTO audit_events (
|
|
1406
|
+
id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
|
|
1407
|
+
) VALUES (?, ?, 'node.link', 'node', ?, ?, ?)`)
|
|
1408
|
+
.bind(input.auditId, input.actorId, input.sourceNodeId, JSON.stringify({ targetNodeIds: input.links.map((link) => link.targetNodeId) }), input.occurredAt),
|
|
1409
|
+
]);
|
|
1410
|
+
},
|
|
1411
|
+
async graphVisible(actor, input) {
|
|
1412
|
+
const nodeResult = await deps.db
|
|
1413
|
+
.prepare(`${visibleCte}
|
|
1414
|
+
SELECT ${nodeColumns}
|
|
1415
|
+
FROM nodes n
|
|
1416
|
+
JOIN allowed ON allowed.id = n.id
|
|
1417
|
+
WHERE n.archived_at IS NULL
|
|
1418
|
+
ORDER BY lower(n.title), n.id
|
|
1419
|
+
LIMIT ?`)
|
|
1420
|
+
.bind(...readBindings(actor), input.limit)
|
|
1421
|
+
.all();
|
|
1422
|
+
const nodes = (nodeResult.results ?? []).map(mapNode);
|
|
1423
|
+
if (nodes.length === 0)
|
|
1424
|
+
return { nodes, links: [] };
|
|
1425
|
+
const linkResult = await deps.db
|
|
1426
|
+
.prepare(`${visibleCte}, graph_nodes(id) AS (
|
|
1427
|
+
SELECT n.id FROM nodes n
|
|
1428
|
+
JOIN allowed ON allowed.id = n.id
|
|
1429
|
+
WHERE n.archived_at IS NULL
|
|
1430
|
+
ORDER BY lower(n.title), n.id
|
|
1431
|
+
LIMIT ?
|
|
1432
|
+
)
|
|
1433
|
+
SELECT ${linkColumns}
|
|
1434
|
+
FROM node_links link
|
|
1435
|
+
JOIN graph_nodes source_node ON source_node.id = link.source_node_id
|
|
1436
|
+
JOIN graph_nodes target_node ON target_node.id = link.target_node_id
|
|
1437
|
+
ORDER BY link.created_at, link.id`)
|
|
1438
|
+
.bind(...readBindings(actor), input.limit)
|
|
1439
|
+
.all();
|
|
1440
|
+
return { nodes, links: (linkResult.results ?? []).map(mapLink) };
|
|
1441
|
+
},
|
|
1442
|
+
/**
|
|
1443
|
+
* The same `allowed` set every other read joins against, narrowed to attachments (#773).
|
|
1444
|
+
*
|
|
1445
|
+
* ⚠️ Ordered and paged by `n.id` rather than by title: the id is the cursor, and a cursor has to
|
|
1446
|
+
* be unique or a page boundary falling between two identical titles silently drops one of them.
|
|
1447
|
+
* `graphVisible` above orders by title because it draws a picture and takes the whole set.
|
|
1448
|
+
*/
|
|
1449
|
+
async listVisibleAttachments(actor, input) {
|
|
1450
|
+
const result = await deps.db
|
|
1451
|
+
.prepare(`${visibleCte}
|
|
1452
|
+
SELECT ${nodeColumns}
|
|
1453
|
+
FROM nodes n
|
|
1454
|
+
JOIN allowed ON allowed.id = n.id
|
|
1455
|
+
WHERE n.archived_at IS NULL
|
|
1456
|
+
AND n.kind = 'attachment'
|
|
1457
|
+
AND (? IS NULL OR n.id > ?)
|
|
1458
|
+
ORDER BY n.id
|
|
1459
|
+
LIMIT ?`)
|
|
1460
|
+
// ⚠️ Positional throughout, and the cursor is bound TWICE. Numbered parameters (?1, ?2)
|
|
1461
|
+
// would count from the first placeholder of the whole statement — and `visibleCte` above
|
|
1462
|
+
// has already spent several of them.
|
|
1463
|
+
.bind(...readBindings(actor), input.after, input.after, input.limit)
|
|
1464
|
+
.all();
|
|
1465
|
+
return (result.results ?? []).map(mapNode);
|
|
1466
|
+
},
|
|
1467
|
+
async setGrant(input) {
|
|
1468
|
+
const grant = input.grant;
|
|
1469
|
+
const { type: principalType, id: principalId } = principalColumns(grant.principal);
|
|
1470
|
+
try {
|
|
1471
|
+
await deps.db.batch([
|
|
1472
|
+
deps.db
|
|
1473
|
+
// The verb is part of the key, so re-granting the same verb only refreshes its expiry
|
|
1474
|
+
// and never turns one verb into another.
|
|
1475
|
+
.prepare(`INSERT INTO node_grants (
|
|
1476
|
+
id, node_id, principal_type, principal_id, verb,
|
|
1477
|
+
expires_at, created_by, created_at
|
|
1478
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
1479
|
+
ON CONFLICT (node_id, principal_type, principal_id, verb)
|
|
1480
|
+
DO UPDATE SET expires_at = excluded.expires_at`)
|
|
1481
|
+
.bind(grant.id, grant.resourceId, principalType, principalId, grant.verb, grant.expiresAt, grant.createdBy, grant.createdAt),
|
|
1482
|
+
deps.db
|
|
1483
|
+
.prepare(`INSERT INTO idempotency_keys (
|
|
1484
|
+
actor_id, operation, idempotency_key, resource_id, created_at
|
|
1485
|
+
) SELECT ?, 'node.share', ?, id, ? FROM node_grants
|
|
1486
|
+
WHERE node_id = ? AND principal_type = ? AND principal_id = ? AND verb = ?`)
|
|
1487
|
+
.bind(input.actorId, input.idempotencyKey, grant.createdAt, grant.resourceId, principalType, principalId, grant.verb),
|
|
1488
|
+
deps.db
|
|
1489
|
+
.prepare(`INSERT INTO audit_events (
|
|
1490
|
+
id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
|
|
1491
|
+
) VALUES (?, ?, 'node.share', 'node', ?, ?, ?)`)
|
|
1492
|
+
.bind(input.auditId, input.actorId, grant.resourceId, JSON.stringify({ principalType, verb: grant.verb }), grant.createdAt),
|
|
1493
|
+
]);
|
|
1494
|
+
const stored = await deps.db
|
|
1495
|
+
.prepare(`SELECT ${grantColumns} FROM node_grants
|
|
1496
|
+
WHERE node_id = ? AND principal_type = ? AND principal_id = ? AND verb = ?`)
|
|
1497
|
+
.bind(grant.resourceId, principalType, principalId, grant.verb)
|
|
1498
|
+
.first();
|
|
1499
|
+
if (!stored)
|
|
1500
|
+
throw new Error("Node grant disappeared after upsert");
|
|
1501
|
+
return mapGrant(stored);
|
|
1502
|
+
}
|
|
1503
|
+
catch (error) {
|
|
1504
|
+
const replayed = await deps.db
|
|
1505
|
+
.prepare(`SELECT resource_id FROM idempotency_keys
|
|
1506
|
+
WHERE actor_id = ? AND operation = 'node.share' AND idempotency_key = ?`)
|
|
1507
|
+
.bind(input.actorId, input.idempotencyKey)
|
|
1508
|
+
.first();
|
|
1509
|
+
if (replayed) {
|
|
1510
|
+
const row = await deps.db
|
|
1511
|
+
.prepare(`SELECT ${grantColumns} FROM node_grants WHERE id = ?`)
|
|
1512
|
+
.bind(replayed.resource_id)
|
|
1513
|
+
.first();
|
|
1514
|
+
if (row)
|
|
1515
|
+
return mapGrant(row);
|
|
1516
|
+
}
|
|
1517
|
+
throw error;
|
|
1518
|
+
}
|
|
1519
|
+
},
|
|
1520
|
+
async revokeGrant(input) {
|
|
1521
|
+
try {
|
|
1522
|
+
await deps.db.batch([
|
|
1523
|
+
deps.db
|
|
1524
|
+
.prepare(`INSERT INTO idempotency_keys (
|
|
1525
|
+
actor_id, operation, idempotency_key, resource_id, created_at
|
|
1526
|
+
) SELECT ?, 'node.revoke', ?,
|
|
1527
|
+
(CASE WHEN EXISTS (
|
|
1528
|
+
SELECT 1 FROM node_grants WHERE id = ? AND node_id = ?
|
|
1529
|
+
) THEN '1:' ELSE '0:' END) || ?, ?`)
|
|
1530
|
+
.bind(input.actorId, input.idempotencyKey, input.grantId, input.resourceId, input.grantId, input.occurredAt),
|
|
1531
|
+
deps.db
|
|
1532
|
+
.prepare("DELETE FROM node_grants WHERE id = ? AND node_id = ?")
|
|
1533
|
+
.bind(input.grantId, input.resourceId),
|
|
1534
|
+
deps.db
|
|
1535
|
+
.prepare(`INSERT INTO audit_events (
|
|
1536
|
+
id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
|
|
1537
|
+
) SELECT ?, ?, 'node.revoke', 'node', ?,
|
|
1538
|
+
json_object(
|
|
1539
|
+
'grantId', ?,
|
|
1540
|
+
'revoked', json(CASE WHEN substr(resource_id, 1, 2) = '1:' THEN 'true' ELSE 'false' END)
|
|
1541
|
+
), ?
|
|
1542
|
+
FROM idempotency_keys
|
|
1543
|
+
WHERE actor_id = ? AND operation = 'node.revoke' AND idempotency_key = ?`)
|
|
1544
|
+
.bind(input.auditId, input.actorId, input.resourceId, input.grantId, input.occurredAt, input.actorId, input.idempotencyKey),
|
|
1545
|
+
]);
|
|
1546
|
+
}
|
|
1547
|
+
catch (error) {
|
|
1548
|
+
const replayed = await this.findIdempotentRevocation(input.actorId, input.idempotencyKey);
|
|
1549
|
+
if (replayed === null)
|
|
1550
|
+
throw error;
|
|
1551
|
+
return replayed;
|
|
1552
|
+
}
|
|
1553
|
+
return (await this.findIdempotentRevocation(input.actorId, input.idempotencyKey)) ?? false;
|
|
1554
|
+
},
|
|
1555
|
+
async searchVisible(actor, input) {
|
|
1556
|
+
const query = toFtsQuery(input.query);
|
|
1557
|
+
if (!query)
|
|
1558
|
+
return [];
|
|
1559
|
+
const result = await deps.db
|
|
1560
|
+
.prepare(`${visibleCte}${scopeCte(input.scopeId)}
|
|
1561
|
+
SELECT n.id AS node_id, v.id AS version_id, n.title,
|
|
1562
|
+
snippet(node_fts, 3, '', '', ' … ', 32) AS passage,
|
|
1563
|
+
n.updated_at AS freshness, bm25(node_fts, 0.0, 0.0, 4.0, 1.0) AS rank
|
|
1564
|
+
FROM node_fts
|
|
1565
|
+
JOIN nodes n ON n.id = node_fts.node_id
|
|
1566
|
+
JOIN node_versions v ON v.id = node_fts.version_id
|
|
1567
|
+
JOIN allowed ON allowed.id = n.id${scopeJoin(input.scopeId)}
|
|
1568
|
+
WHERE node_fts MATCH ?
|
|
1569
|
+
AND n.current_version_id = v.id
|
|
1570
|
+
AND n.archived_at IS NULL
|
|
1571
|
+
ORDER BY rank, n.updated_at DESC
|
|
1572
|
+
LIMIT ?`)
|
|
1573
|
+
.bind(...readBindings(actor), ...scopeBindings(input.scopeId), query, input.limit)
|
|
1574
|
+
.all();
|
|
1575
|
+
// ⚠️ One citation per node, and the FIRST is the one kept. The index is CHUNKED — a node
|
|
1576
|
+
// may hold several rows (#285) — so a query matching three of them arrives here three times;
|
|
1577
|
+
// the rows come back in rank order, so the survivor is the best-matching card and its own
|
|
1578
|
+
// passage is what the reader is shown. Without this, one busy node could fill a whole page
|
|
1579
|
+
// of results and push every other node off it.
|
|
1580
|
+
return dedupedByNode(result.results ?? []).map((row) => ({
|
|
1581
|
+
nodeId: row.node_id,
|
|
1582
|
+
versionId: row.version_id,
|
|
1583
|
+
title: row.title,
|
|
1584
|
+
passage: row.passage,
|
|
1585
|
+
source: `intel://nodes/${encodeURIComponent(row.node_id)}?version=${encodeURIComponent(row.version_id)}`,
|
|
1586
|
+
freshness: row.freshness,
|
|
1587
|
+
score: 1 / (1 + Math.abs(row.rank)),
|
|
1588
|
+
match: "lexical",
|
|
1589
|
+
}));
|
|
1590
|
+
},
|
|
1591
|
+
async hydrateVisibleCitations(actor, hits, scopeId) {
|
|
1592
|
+
// One entry per node: the caller has already picked the best-scoring chunk of each, so a
|
|
1593
|
+
// node that answered with four passages arrives as the one passage that answered best.
|
|
1594
|
+
const unique = [...new Map(hits.map((hit) => [hit.nodeId, hit])).values()].slice(0, 100);
|
|
1595
|
+
if (unique.length === 0)
|
|
1596
|
+
return [];
|
|
1597
|
+
const rows = [];
|
|
1598
|
+
// Two bindings per hit now instead of one, so the batch is halved and then some: the actor's
|
|
1599
|
+
// predicate and the optional scope are bound in front of them, and D1 counts every one of
|
|
1600
|
+
// them against the same per-statement ceiling.
|
|
1601
|
+
for (let offset = 0; offset < unique.length; offset += 40) {
|
|
1602
|
+
const batch = unique.slice(offset, offset + 40);
|
|
1603
|
+
const pairs = batch.map(() => "(?, ?)").join(", ");
|
|
1604
|
+
const result = await deps.db
|
|
1605
|
+
.prepare(`${visibleCte}${scopeCte(scopeId)},
|
|
1606
|
+
hits(node_id, chunk_key) AS (VALUES ${pairs})
|
|
1607
|
+
SELECT n.id AS node_id, v.id AS version_id, n.title,
|
|
1608
|
+
-- ⚠️ The card that answered, and only otherwise the first passage of the node
|
|
1609
|
+
-- (anchrd/intel#301). Every vector written before #301 is named by the bare node id
|
|
1610
|
+
-- and has no row here, so the fallback is not defensive coding but the exact
|
|
1611
|
+
-- behaviour those vectors had — a board found through one of them still answers,
|
|
1612
|
+
-- with the passage it always answered with, until the next indexing pass.
|
|
1613
|
+
COALESCE(vector.passage, substr(node_fts.content, 1, 480)) AS passage,
|
|
1614
|
+
n.updated_at AS freshness
|
|
1615
|
+
FROM hits
|
|
1616
|
+
JOIN nodes n ON n.id = hits.node_id
|
|
1617
|
+
JOIN node_versions v ON v.id = n.current_version_id
|
|
1618
|
+
JOIN node_fts ON node_fts.version_id = v.id
|
|
1619
|
+
LEFT JOIN node_vectors vector
|
|
1620
|
+
ON vector.node_id = hits.node_id AND vector.chunk_key = hits.chunk_key
|
|
1621
|
+
JOIN allowed ON allowed.id = n.id${scopeJoin(scopeId)}
|
|
1622
|
+
WHERE n.archived_at IS NULL`)
|
|
1623
|
+
.bind(...readBindings(actor), ...scopeBindings(scopeId), ...batch.flatMap((hit) => [hit.nodeId, hit.chunkKey]))
|
|
1624
|
+
.all();
|
|
1625
|
+
rows.push(...(result.results ?? []));
|
|
1626
|
+
}
|
|
1627
|
+
const order = new Map(unique.map((hit, index) => [hit.nodeId, index]));
|
|
1628
|
+
// The same one-per-node rule as the lexical half above, for the same reason: a citation names
|
|
1629
|
+
// a NODE, and a board holds one full-text row per task (#285), so the join above still
|
|
1630
|
+
// produces one row per card even though only one of them was asked about.
|
|
1631
|
+
const deduped = dedupedByNode(rows);
|
|
1632
|
+
deduped.sort((left, right) => (order.get(left.node_id) ?? 0) - (order.get(right.node_id) ?? 0));
|
|
1633
|
+
return deduped.map((row) => ({
|
|
1634
|
+
nodeId: row.node_id,
|
|
1635
|
+
versionId: row.version_id,
|
|
1636
|
+
title: row.title,
|
|
1637
|
+
passage: row.passage,
|
|
1638
|
+
source: `intel://nodes/${encodeURIComponent(row.node_id)}?version=${encodeURIComponent(row.version_id)}`,
|
|
1639
|
+
freshness: row.freshness,
|
|
1640
|
+
score: 0,
|
|
1641
|
+
match: "semantic",
|
|
1642
|
+
}));
|
|
1643
|
+
},
|
|
1644
|
+
async invalidateVectors() {
|
|
1645
|
+
/**
|
|
1646
|
+
* ⚠️ The fingerprints are cleared and the ROWS are kept, which is not a detail. A record row
|
|
1647
|
+
* says two things at once: "this chunk was embedded from this text" and "this vector exists".
|
|
1648
|
+
* Only the first is stale after a rebuild is asked for; deleting the row would throw the
|
|
1649
|
+
* second away, and then a card removed between this call and that board's pass would leave a
|
|
1650
|
+
* vector nothing could ever name again — matching questions and answering with a card that is
|
|
1651
|
+
* not on the board.
|
|
1652
|
+
*
|
|
1653
|
+
* The empty string is a value no digest produces, so every chunk compares as changed and the
|
|
1654
|
+
* next pass embeds all of them. `node_fts` is deliberately not touched: its rows are
|
|
1655
|
+
* overwritten by the pass that rewrites them, and emptying it would take the lexical half of
|
|
1656
|
+
* the installation offline for as long as the queue needs to work through the tree.
|
|
1657
|
+
*/
|
|
1658
|
+
await deps.db.prepare("UPDATE node_vectors SET fingerprint = ''").run();
|
|
1659
|
+
},
|
|
1660
|
+
/**
|
|
1661
|
+
* ⚠️ Archived nodes are IN this walk since anchrd/intel#348, and the omission of
|
|
1662
|
+
* `archived_at IS NULL` is the point. The pass an archived node gets is a purge, not an index —
|
|
1663
|
+
* `getTarget` refuses it and `archivedNodeId` says why — so including them is what makes
|
|
1664
|
+
* `reindex` the way back for a vector that is in the index with nothing left to name it.
|
|
1665
|
+
*
|
|
1666
|
+
* Three of those exist and none of them has another repair: everything archived before #348,
|
|
1667
|
+
* which nothing ever swept; a node whose `archive` wrote its row and then failed to reach the
|
|
1668
|
+
* queue; and a pass that upserted vectors in the moment somebody archived the node underneath
|
|
1669
|
+
* it. Without this line an administrator's rebuild walked straight past all three, and the only
|
|
1670
|
+
* remaining answer would have been to delete the whole Vectorize index by hand.
|
|
1671
|
+
*
|
|
1672
|
+
* It does mean `queued` counts them. That is still what the number says — versions handed to the
|
|
1673
|
+
* queue — and a rebuild that silently skipped part of the tree is the failure `reindex` exists
|
|
1674
|
+
* to prevent.
|
|
1675
|
+
*/
|
|
1676
|
+
async listCurrentVersionIds(input) {
|
|
1677
|
+
const result = await deps.db
|
|
1678
|
+
.prepare(`SELECT current_version_id AS id FROM nodes
|
|
1679
|
+
WHERE current_version_id IS NOT NULL
|
|
1680
|
+
AND (? IS NULL OR current_version_id > ?)
|
|
1681
|
+
ORDER BY id LIMIT ?`)
|
|
1682
|
+
.bind(input.after, input.after, input.limit)
|
|
1683
|
+
.all();
|
|
1684
|
+
return (result.results ?? []).map((row) => row.id);
|
|
1685
|
+
},
|
|
1686
|
+
};
|
|
1687
|
+
}
|