@agent-native/core-corpus 0.216.0 → 0.217.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/corpus/README.md +1 -1
- package/corpus/templates/analytics/.agents/skills/session-replay/SKILL.md +61 -1
- package/corpus/templates/analytics/AGENTS.md +1 -1
- package/corpus/templates/analytics/CHANGELOG.md +1 -0
- package/corpus/templates/analytics/actions/get-session-replay-summary.ts +9 -65
- package/corpus/templates/analytics/actions/list-event-catalog.ts +1 -1
- package/corpus/templates/analytics/actions/list-session-outputs.ts +40 -0
- package/corpus/templates/analytics/actions/list-session-recordings.ts +17 -4
- package/corpus/templates/analytics/actions/view-screen.ts +29 -3
- package/corpus/templates/analytics/app/i18n/en-US.ts +27 -0
- package/corpus/templates/analytics/app/pages/sessions/EventCatalogPage.tsx +18 -1
- package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +3 -1
- package/corpus/templates/analytics/app/pages/sessions/SessionOutputs.tsx +197 -0
- package/corpus/templates/analytics/app/pages/sessions/SessionsTriagePage.tsx +114 -4
- package/corpus/templates/analytics/changelog/2026-10-15-sessions-can-find-people-who-made-a-slides-deck-but-didn-t-p.md +6 -0
- package/corpus/templates/analytics/changelog/2026-10-16-the-activation-funnel-counts-visitors-who-decline-analytics-.md +6 -0
- package/corpus/templates/analytics/server/db/schema.ts +67 -0
- package/corpus/templates/analytics/server/lib/canonical-first-party-dashboard-repair.ts +9 -0
- package/corpus/templates/analytics/server/lib/first-party-analytics.ts +28 -22
- package/corpus/templates/analytics/server/lib/first-party-metric-catalog.ts +36 -1
- package/corpus/templates/analytics/server/lib/output-outcomes.ts +712 -0
- package/corpus/templates/analytics/server/lib/replay-origins.ts +33 -0
- package/corpus/templates/analytics/server/lib/session-event-index.ts +20 -0
- package/corpus/templates/analytics/server/lib/session-performance.ts +6 -40
- package/corpus/templates/analytics/server/lib/session-recording-associations.ts +30 -1
- package/corpus/templates/analytics/server/lib/session-replay-triage.ts +99 -0
- package/corpus/templates/analytics/server/lib/session-replay.ts +45 -31
- package/corpus/templates/analytics/server/lib/sessions-triage-lab.ts +8 -2
- package/corpus/templates/analytics/server/plugins/db.ts +46 -0
- package/corpus/templates/analytics/shared/output-use.ts +205 -0
- package/corpus/templates/analytics/shared/session-events.ts +6 -0
- package/corpus/templates/calendar/CHANGELOG.md +6 -0
- package/corpus/templates/calendar/actions/create-event.ts +2 -4
- package/corpus/templates/calendar/actions/update-event.ts +35 -6
- package/corpus/templates/calendar/changelog/2026-10-21-solo-events-avoid-notification-approval.md +6 -0
- package/corpus/templates/community/qr/package.json +1 -1
- package/corpus/templates/content/.agents/skills/document-editing/references/databases.md +6 -3
- package/corpus/templates/content/CHANGELOG.md +8 -0
- package/corpus/templates/content/actions/_browser-document-save-attempt.ts +23 -1
- package/corpus/templates/content/actions/_builder-cms-blob-custody.ts +467 -0
- package/corpus/templates/content/actions/_builder-cms-execution-lease.ts +17 -0
- package/corpus/templates/content/actions/_builder-cms-intent-lookup.ts +5 -0
- package/corpus/templates/content/actions/_builder-cms-read-client.ts +881 -21
- package/corpus/templates/content/actions/_builder-cms-write-adapter.ts +28 -2
- package/corpus/templates/content/actions/_builder-cms-write-client.ts +109 -11
- package/corpus/templates/content/actions/_builder-docs-client.ts +1 -0
- package/corpus/templates/content/actions/_builder-private-blob.test-fixture.ts +90 -0
- package/corpus/templates/content/actions/_builder-source-execution-guard.ts +48 -0
- package/corpus/templates/content/actions/_content-save-outcomes.ts +5 -0
- package/corpus/templates/content/actions/_database-source-utils.ts +290 -8
- package/corpus/templates/content/actions/_database-utils.ts +93 -7
- package/corpus/templates/content/actions/_database-view-setup.ts +21 -2
- package/corpus/templates/content/actions/_delete-content-space.ts +8 -1
- package/corpus/templates/content/actions/_github-source-freshness.ts +30 -4
- package/corpus/templates/content/actions/add-content-database-source-field-property.ts +23 -6
- package/corpus/templates/content/actions/cancel-prepared-builder-source-update.ts +110 -26
- package/corpus/templates/content/actions/change-content-database-source-role.ts +51 -7
- package/corpus/templates/content/actions/delete-document.ts +41 -3
- package/corpus/templates/content/actions/disconnect-content-database-source.ts +68 -2
- package/corpus/templates/content/actions/disconnect-local-folder-source.ts +33 -2
- package/corpus/templates/content/actions/execute-builder-source-execution.ts +900 -140
- package/corpus/templates/content/actions/get-document.ts +2 -0
- package/corpus/templates/content/actions/permanently-delete-document.ts +6 -0
- package/corpus/templates/content/actions/prepare-builder-source-execution.ts +155 -13
- package/corpus/templates/content/actions/prepare-builder-source-review.ts +299 -73
- package/corpus/templates/content/actions/set-live-body-switch.ts +45 -0
- package/corpus/templates/content/actions/update-document.ts +139 -20
- package/corpus/templates/content/actions/validate-builder-source-execution.ts +135 -26
- package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +23 -0
- package/corpus/templates/content/app/components/editor/database/GitHubSourceStatusLine.tsx +27 -2
- package/corpus/templates/content/app/components/editor/database/ListItemLayout.tsx +32 -8
- package/corpus/templates/content/app/components/editor/database/ListItemRow.tsx +27 -2
- package/corpus/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx +1 -0
- package/corpus/templates/content/app/hooks/use-content-database.ts +40 -2
- package/corpus/templates/content/app/hooks/use-documents.ts +14 -0
- package/corpus/templates/content/app/i18n-data.ts +1 -0
- package/corpus/templates/content/changelog/2026-10-17-builder-content-avoids-retrying-a-write-when-builder-accepte.md +6 -0
- package/corpus/templates/content/changelog/2026-10-18-link-a-new-collection-to-a-github-folder-from-its-sources.md +1 -1
- package/corpus/templates/content/changelog/2026-10-20-builder-content-sync-keeps-read-and-write-status-accurate-wh.md +6 -0
- package/corpus/templates/content/changelog/2026-10-21-list-rows-can-show-each-pages-first-lines.md +6 -0
- package/corpus/templates/content/changelog/2026-10-22-a-collection-linked-to-a-big-github-folder-fills-faster-and-p.md +6 -0
- package/corpus/templates/content/docs/product/capabilities/content.source.adapters.md +29 -0
- package/corpus/templates/content/parity/matrix.md +42 -42
- package/corpus/templates/content/parity/matrix.ts +3 -1
- package/corpus/templates/content/server/db/schema.ts +20 -0
- package/corpus/templates/content/server/lib/content-trash-purge.ts +15 -1
- package/corpus/templates/content/server/lib/live-body-writer.ts +138 -26
- package/corpus/templates/content/server/live-copy/binding.ts +31 -0
- package/corpus/templates/content/server/live-copy/bounded-task.d.ts +6 -0
- package/corpus/templates/content/server/live-copy/exec-db.ts +1 -0
- package/corpus/templates/content/server/live-copy/holds.ts +59 -0
- package/corpus/templates/content/server/live-copy/persist.ts +656 -0
- package/corpus/templates/content/server/live-copy/switch.ts +136 -0
- package/corpus/templates/content/server/live-copy/task-names.ts +4 -0
- package/corpus/templates/content/server/live-copy/tasks.ts +183 -0
- package/corpus/templates/content/server/live-copy/write-cause.ts +85 -0
- package/corpus/templates/content/server/live-copy/write.ts +85 -0
- package/corpus/templates/content/server/plugins/builder-private-payload-cleanup.ts +22 -0
- package/corpus/templates/content/server/plugins/collab.ts +3 -0
- package/corpus/templates/content/server/plugins/db.ts +28 -0
- package/corpus/templates/content/shared/api.ts +30 -1
- package/corpus/templates/content/shared/database-item-layout.ts +54 -22
- package/corpus/templates/content/shared/document-intent-merge.ts +16 -9
- package/corpus/templates/content/shared/og-description.ts +258 -36
- package/corpus/templates/design/.agents/skills/design-templates/SKILL.md +5 -1
- package/corpus/templates/design/.generated/bridge/shader-runtime.generated.ts +25 -4
- package/corpus/templates/design/CHANGELOG.md +2 -0
- package/corpus/templates/design/actions/get-getting-started.ts +53 -0
- package/corpus/templates/design/app/components/design/DesignCanvas.tsx +10 -0
- package/corpus/templates/design/app/components/design/bridge/native-shader-runtime.bridge.ts +132 -79
- package/corpus/templates/design/app/components/design/bridge/native-source-provider.ts +268 -130
- package/corpus/templates/design/app/components/design/bridge/native-worker-scene-host.ts +122 -42
- package/corpus/templates/design/app/components/design/bridge/shader-runtime.bridge.ts +41 -4
- package/corpus/templates/design/app/components/design/design-canvas/text-content-change-result.ts +46 -12
- package/corpus/templates/design/app/components/home/DesignHomeLibrary.tsx +15 -8
- package/corpus/templates/design/app/components/layout/GettingStarted.tsx +50 -0
- package/corpus/templates/design/app/components/layout/Sidebar.tsx +3 -0
- package/corpus/templates/design/app/components/templates/DesignTemplateGrid.tsx +163 -0
- package/corpus/templates/design/app/components/templates/DesignTemplateLibrary.tsx +65 -78
- package/corpus/templates/design/app/components/templates/TemplatePreview.tsx +6 -2
- package/corpus/templates/design/app/global.css +6 -0
- package/corpus/templates/design/app/i18n/en-US.ts +17 -0
- package/corpus/templates/design/app/lib/canvas-load-outcome.ts +147 -0
- package/corpus/templates/design/app/lib/canvas-outcome-tracking.ts +531 -0
- package/corpus/templates/design/app/lib/canvas-style-kind.ts +50 -0
- package/corpus/templates/design/app/pages/Index.tsx +10 -3
- package/corpus/templates/design/app/pages/design-editor/commands/commit-visual-styles.ts +86 -29
- package/corpus/templates/design/app/pages/design-editor/commands/save-file-content.ts +38 -3
- package/corpus/templates/design/app/pages/design-editor/domains/use-editor-core.ts +9 -0
- package/corpus/templates/design/app/pages/design-editor/domains/use-editor-files-and-saving.ts +2 -0
- package/corpus/templates/design/changelog/2026-10-21-a-getting-started-checklist-at-the-bottom-of-the-sidebar-tra.md +6 -0
- package/corpus/templates/design/changelog/2026-10-21-templates-now-open-a-read-only-preview-from-the-card-and-the.md +6 -0
- package/corpus/templates/design/changelog/2026-10-22-collapsed-shader-layers-no-longer-interrupt-other-shaders-on.md +6 -0
- package/corpus/templates/design/changelog/2026-10-22-shader-fills-keep-rendering-when-other-screen-content-cannot.md +6 -0
- package/corpus/templates/design/scripts/design-export-validation/export-baseline.json +24 -0
- package/corpus/templates/design/server/lib/getting-started.ts +267 -0
- package/corpus/templates/design/server/plugins/getting-started.ts +5 -0
- package/corpus/templates/design/shared/design-template-presets/analytics-dashboard.ts +127 -0
- package/corpus/templates/design/shared/design-template-presets/design-studio.ts +98 -0
- package/corpus/templates/design/shared/design-template-presets/messaging-app.ts +101 -0
- package/corpus/templates/design/shared/design-template-presets/project-tasks.ts +120 -0
- package/corpus/templates/design/shared/design-template-presets.ts +8 -0
- package/corpus/templates/forms/server/plugins/agent-chat.ts +1 -1
- package/corpus/templates/slides/CHANGELOG.md +6 -0
- package/corpus/templates/slides/actions/delete-deck.ts +7 -0
- package/corpus/templates/slides/changelog/2026-10-21-connected-deck-deletion-requires-approval.md +6 -0
- package/corpus/templates/slides/scripts/edit-fidelity/client-action.ts +61 -0
- package/corpus/templates/slides/scripts/edit-fidelity/run.ts +10 -0
- package/package.json +1 -1
|
@@ -20,6 +20,8 @@ export const documents = table("documents", {
|
|
|
20
20
|
bodyRevision: integer("body_revision").notNull().default(0),
|
|
21
21
|
collabBodyRevision: integer("collab_body_revision"),
|
|
22
22
|
liveBodySwitchedAt: text("live_body_switched_at"),
|
|
23
|
+
// `_collab_docs.version` the stored body was last derived from.
|
|
24
|
+
liveCopyVersion: integer("live_copy_version"),
|
|
23
25
|
description: text("description").notNull().default(""),
|
|
24
26
|
icon: text("icon"),
|
|
25
27
|
position: integer("position").notNull().default(0),
|
|
@@ -802,6 +804,24 @@ export const contentDatabaseSourceExecutions = table(
|
|
|
802
804
|
},
|
|
803
805
|
);
|
|
804
806
|
|
|
807
|
+
export const contentDatabaseBuilderBlobCleanup = table(
|
|
808
|
+
"content_database_builder_blob_cleanup",
|
|
809
|
+
{
|
|
810
|
+
blobReference: text("blob_reference").primaryKey(),
|
|
811
|
+
createdAt: text("created_at").notNull().default(now()),
|
|
812
|
+
attemptCount: integer("attempt_count").notNull().default(0),
|
|
813
|
+
nextAttemptAt: text("next_attempt_at")
|
|
814
|
+
.notNull()
|
|
815
|
+
.default("1970-01-01T00:00:00.000Z"),
|
|
816
|
+
},
|
|
817
|
+
(cleanup) => [
|
|
818
|
+
index("content_database_builder_blob_cleanup_due_idx").on(
|
|
819
|
+
cleanup.nextAttemptAt,
|
|
820
|
+
cleanup.createdAt,
|
|
821
|
+
),
|
|
822
|
+
],
|
|
823
|
+
);
|
|
824
|
+
|
|
805
825
|
export const contentDatabaseSourceExecutionClaims = table(
|
|
806
826
|
"content_database_source_execution_claims",
|
|
807
827
|
{
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
} from "drizzle-orm";
|
|
27
27
|
import { alias } from "drizzle-orm/pg-core";
|
|
28
28
|
|
|
29
|
+
import { cleanupBuilderPrivatePayload } from "../../actions/_builder-cms-blob-custody.js";
|
|
29
30
|
import { contentTrashPredicates } from "../../actions/_content-trash-query.js";
|
|
30
31
|
import {
|
|
31
32
|
deleteTrashedDocumentSubtree,
|
|
@@ -305,6 +306,7 @@ export async function processContentTrashPurge(operationId: string) {
|
|
|
305
306
|
}
|
|
306
307
|
|
|
307
308
|
const units = unitToDelete ? [unitToDelete] : [];
|
|
309
|
+
const deletedBlobReferences = new Set<string>();
|
|
308
310
|
let deletedDelta = 0;
|
|
309
311
|
for (const unit of units) {
|
|
310
312
|
const frozen = allPending.filter(
|
|
@@ -316,6 +318,7 @@ export async function processContentTrashPurge(operationId: string) {
|
|
|
316
318
|
transactionDb,
|
|
317
319
|
unit.rootDocumentId,
|
|
318
320
|
unit.ownerEmail,
|
|
321
|
+
deletedBlobReferences,
|
|
319
322
|
frozen,
|
|
320
323
|
frozen[0]?.expectedScopeFingerprint,
|
|
321
324
|
);
|
|
@@ -372,12 +375,23 @@ export async function processContentTrashPurge(operationId: string) {
|
|
|
372
375
|
});
|
|
373
376
|
if (!countReceipt) throw new ContentTrashPurgeLeaseLostError();
|
|
374
377
|
}
|
|
375
|
-
return {
|
|
378
|
+
return {
|
|
379
|
+
blockedDelta,
|
|
380
|
+
deletedDelta,
|
|
381
|
+
deletedBlobReferences,
|
|
382
|
+
unitCount: units.length,
|
|
383
|
+
};
|
|
376
384
|
},
|
|
377
385
|
{ isolationLevel: "serializable" },
|
|
378
386
|
);
|
|
379
387
|
claimed.blockedCount += batchResult.blockedDelta;
|
|
380
388
|
claimed.deletedCount += batchResult.deletedDelta;
|
|
389
|
+
for (const reference of batchResult.deletedBlobReferences) {
|
|
390
|
+
await cleanupBuilderPrivatePayload(
|
|
391
|
+
reference,
|
|
392
|
+
"deleted document source",
|
|
393
|
+
);
|
|
394
|
+
}
|
|
381
395
|
if (batchResult.unitCount === 0) break;
|
|
382
396
|
}
|
|
383
397
|
} catch (error) {
|
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
evaluateFeatureFlagRules,
|
|
4
4
|
getFeatureFlagRulesStrict,
|
|
5
5
|
} from "@agent-native/core/feature-flags";
|
|
6
|
-
import { eq } from "drizzle-orm";
|
|
6
|
+
import { eq, sql } from "drizzle-orm";
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
9
|
LIVE_BODY_SHADOW_FLAG,
|
|
@@ -19,11 +19,14 @@ import { schema } from "../db/index.js";
|
|
|
19
19
|
// The flags are evaluated for the page, not the caller. Two collaborators with
|
|
20
20
|
// different flags would otherwise write one body down two paths at once.
|
|
21
21
|
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
22
|
+
// Two markers make the kill switch safe, one per side. While the flags are
|
|
23
|
+
// off, a switched page takes legacy SQL writes its live copy never sees, and
|
|
24
|
+
// editor updates that commit to Yjs on main's path, where nothing derives the
|
|
25
|
+
// stored body. A legacy write bumps only `body_revision`, so
|
|
26
|
+
// `collab_body_revision` falls behind it; a Yjs commit bumps only
|
|
27
|
+
// `_collab_docs.version`, so `live_copy_version` falls behind that. Either
|
|
28
|
+
// keeps the page on the legacy path until it is converted again; deriving
|
|
29
|
+
// from a copy out of step with the stored body would overwrite one side.
|
|
27
30
|
|
|
28
31
|
export type LiveBodyWritePath = "live_copy" | "legacy";
|
|
29
32
|
|
|
@@ -31,6 +34,7 @@ export type LiveBodyLegacyReason =
|
|
|
31
34
|
| "no_page"
|
|
32
35
|
| "not_switched"
|
|
33
36
|
| "live_copy_behind"
|
|
37
|
+
| "stored_body_behind"
|
|
34
38
|
| "flag_off";
|
|
35
39
|
|
|
36
40
|
export type LiveBodyWriterGate =
|
|
@@ -39,55 +43,144 @@ export type LiveBodyWriterGate =
|
|
|
39
43
|
|
|
40
44
|
export type LiveBodyWriterPageRow = Pick<
|
|
41
45
|
typeof schema.documents.$inferSelect,
|
|
46
|
+
| "id"
|
|
42
47
|
| "liveBodySwitchedAt"
|
|
43
48
|
| "bodyRevision"
|
|
44
49
|
| "collabBodyRevision"
|
|
50
|
+
| "liveCopyVersion"
|
|
45
51
|
| "ownerEmail"
|
|
46
52
|
| "orgId"
|
|
47
53
|
>;
|
|
48
54
|
|
|
49
55
|
export const liveBodyWriterPageColumns = {
|
|
56
|
+
id: schema.documents.id,
|
|
50
57
|
liveBodySwitchedAt: schema.documents.liveBodySwitchedAt,
|
|
51
58
|
bodyRevision: schema.documents.bodyRevision,
|
|
52
59
|
collabBodyRevision: schema.documents.collabBodyRevision,
|
|
60
|
+
liveCopyVersion: schema.documents.liveCopyVersion,
|
|
53
61
|
ownerEmail: schema.documents.ownerEmail,
|
|
54
62
|
orgId: schema.documents.orgId,
|
|
55
63
|
};
|
|
56
64
|
|
|
57
65
|
/**
|
|
58
|
-
* Decides the write path for a page row the caller
|
|
59
|
-
*
|
|
60
|
-
* read in it rather than from the request's settings snapshot.
|
|
61
|
-
*
|
|
62
|
-
* page's writes down
|
|
66
|
+
* Decides the write path for a page row the caller read under its row lock;
|
|
67
|
+
* pass that transaction so the flags and the stored live copy's version are
|
|
68
|
+
* read in it rather than from the request's settings snapshot. A caller
|
|
69
|
+
* without the lock uses `liveBodyWriterGate`. Unreadable or malformed flag
|
|
70
|
+
* state throws: reading it as "off" would send a switched page's writes down
|
|
71
|
+
* the legacy path.
|
|
63
72
|
*/
|
|
64
73
|
export async function liveBodyWriterGateForRow(
|
|
65
74
|
row: LiveBodyWriterPageRow | null | undefined,
|
|
66
75
|
options: { transaction?: DbExec } = {},
|
|
76
|
+
): Promise<LiveBodyWriterGate> {
|
|
77
|
+
return gateForMarkers(
|
|
78
|
+
row,
|
|
79
|
+
async (documentId) => {
|
|
80
|
+
const { rows } = await (options.transaction ?? getDbExec()).execute({
|
|
81
|
+
sql: "SELECT version FROM _collab_docs WHERE doc_id = ?",
|
|
82
|
+
args: [documentId],
|
|
83
|
+
});
|
|
84
|
+
return (rows[0] as { version?: unknown } | undefined)?.version;
|
|
85
|
+
},
|
|
86
|
+
options,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function gateForMarkers(
|
|
91
|
+
row: LiveBodyWriterPageRow | null | undefined,
|
|
92
|
+
readCollabVersion: (documentId: string) => Promise<unknown>,
|
|
93
|
+
options: { transaction?: DbExec },
|
|
67
94
|
): Promise<LiveBodyWriterGate> {
|
|
68
95
|
if (!row) return { writePath: "legacy", reason: "no_page" };
|
|
69
96
|
if (!row.liveBodySwitchedAt)
|
|
70
97
|
return { writePath: "legacy", reason: "not_switched" };
|
|
71
98
|
if (row.collabBodyRevision !== row.bodyRevision)
|
|
72
99
|
return { writePath: "legacy", reason: "live_copy_behind" };
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
await getFeatureFlagRulesStrict(flag.key, {
|
|
79
|
-
...scope,
|
|
80
|
-
transaction: options.transaction,
|
|
81
|
-
}),
|
|
82
|
-
scope,
|
|
83
|
-
),
|
|
84
|
-
),
|
|
100
|
+
// The stored version, not the one an edit was built on: a peer's commit
|
|
101
|
+
// moves both markers, and core's fence retries the overtaken edit.
|
|
102
|
+
const collabVersion = parseCollabVersion(
|
|
103
|
+
row.id,
|
|
104
|
+
await readCollabVersion(row.id),
|
|
85
105
|
);
|
|
86
|
-
|
|
106
|
+
if (collabVersion === null || row.liveCopyVersion !== collabVersion)
|
|
107
|
+
return { writePath: "legacy", reason: "stored_body_behind" };
|
|
108
|
+
return (await liveBodyWriterFlagsOn(row, options))
|
|
87
109
|
? { writePath: "live_copy" }
|
|
88
110
|
: { writePath: "legacy", reason: "flag_off" };
|
|
89
111
|
}
|
|
90
112
|
|
|
113
|
+
// Core's version for the page's collab doc; null when it has none.
|
|
114
|
+
function parseCollabVersion(documentId: string, version: unknown) {
|
|
115
|
+
if (version === undefined || version === null) return null;
|
|
116
|
+
const parsed = Number(version);
|
|
117
|
+
if (!Number.isSafeInteger(parsed)) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`Page ${documentId}'s collab doc has an unreadable version (${String(version)}).`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
return parsed;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Both flags, for the page's owner and org; throws like the gate. */
|
|
126
|
+
export async function liveBodyWriterFlagsOn(
|
|
127
|
+
row: Pick<LiveBodyWriterPageRow, "ownerEmail" | "orgId">,
|
|
128
|
+
options: { transaction?: DbExec } = {},
|
|
129
|
+
): Promise<boolean> {
|
|
130
|
+
const [writer, shadow] = await Promise.all(
|
|
131
|
+
[LIVE_BODY_WRITER_FLAG, LIVE_BODY_SHADOW_FLAG].map((flag) =>
|
|
132
|
+
flagOnForPage(flag.key, row, options.transaction),
|
|
133
|
+
),
|
|
134
|
+
);
|
|
135
|
+
return writer && shadow;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function flagOnForPage(
|
|
139
|
+
key: string,
|
|
140
|
+
row: Pick<LiveBodyWriterPageRow, "ownerEmail" | "orgId">,
|
|
141
|
+
transaction: DbExec | undefined,
|
|
142
|
+
): Promise<boolean> {
|
|
143
|
+
const scope = { userEmail: row.ownerEmail, orgId: row.orgId };
|
|
144
|
+
const rules = await getFeatureFlagRulesStrict(key, { ...scope, transaction });
|
|
145
|
+
if (evaluateFeatureFlagRules(key, rules, scope)) return true;
|
|
146
|
+
if (row.orgId) return false;
|
|
147
|
+
// `enable-for-current-user` stores an enablement in the org its manager had
|
|
148
|
+
// active, but a personal page has no org, so look for the owner's own
|
|
149
|
+
// listing in each of their orgs. Only that listing counts: an org's rollout
|
|
150
|
+
// covers the org's pages, not its members' personal ones.
|
|
151
|
+
const owner = row.ownerEmail.trim().toLowerCase();
|
|
152
|
+
for (const orgId of await ownerOrgIds(owner, transaction)) {
|
|
153
|
+
const orgRules = await getFeatureFlagRulesStrict(key, {
|
|
154
|
+
orgId,
|
|
155
|
+
transaction,
|
|
156
|
+
});
|
|
157
|
+
if (orgRules.mode !== "off" && orgRules.emails.includes(owner)) return true;
|
|
158
|
+
}
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Read here rather than through core's membership cache, which answers for the
|
|
163
|
+
// request's caller and turns an unreadable table into "no orgs".
|
|
164
|
+
async function ownerOrgIds(
|
|
165
|
+
owner: string,
|
|
166
|
+
transaction: DbExec | undefined,
|
|
167
|
+
): Promise<string[]> {
|
|
168
|
+
const exec = transaction ?? getDbExec();
|
|
169
|
+
const relation = await exec.execute({
|
|
170
|
+
sql: "SELECT to_regclass('org_members') AS relation",
|
|
171
|
+
args: [],
|
|
172
|
+
});
|
|
173
|
+
// A deployment without organizations has no memberships.
|
|
174
|
+
if (!relation.rows[0]?.relation) return [];
|
|
175
|
+
const { rows } = await exec.execute({
|
|
176
|
+
sql: `SELECT org_id FROM org_members
|
|
177
|
+
WHERE LOWER(email) = ? AND federation_removal_pending_at IS NULL
|
|
178
|
+
ORDER BY org_id`,
|
|
179
|
+
args: [owner],
|
|
180
|
+
});
|
|
181
|
+
return rows.map((row) => String((row as { org_id: unknown }).org_id));
|
|
182
|
+
}
|
|
183
|
+
|
|
91
184
|
// Request reads retain settings initialization and the request snapshot. A
|
|
92
185
|
// transaction context is explicit: an executor's shape does not identify a
|
|
93
186
|
// transaction. Ordinary database adapters enter through core's withDbExec.
|
|
@@ -173,6 +266,7 @@ function readContext(options: LiveBodyWriterReadOptions): {
|
|
|
173
266
|
throw new LiveBodyWriterContextError();
|
|
174
267
|
}
|
|
175
268
|
|
|
269
|
+
/** The write path for a page, read without its row lock. */
|
|
176
270
|
export async function liveBodyWriterGate(
|
|
177
271
|
docId: string,
|
|
178
272
|
options: LiveBodyWriterReadOptions = {},
|
|
@@ -184,8 +278,26 @@ export async function liveBodyWriterGate(
|
|
|
184
278
|
.from(schema.documents)
|
|
185
279
|
.where(eq(schema.documents.id, docId))
|
|
186
280
|
.limit(1);
|
|
187
|
-
|
|
188
|
-
|
|
281
|
+
// Most pages stop here, before `_collab_docs`, which a deployment creates
|
|
282
|
+
// with its first collab doc.
|
|
283
|
+
if (!row) return { writePath: "legacy", reason: "no_page" };
|
|
284
|
+
if (!row.liveBodySwitchedAt)
|
|
285
|
+
return { writePath: "legacy", reason: "not_switched" };
|
|
286
|
+
// A live-copy commit moves the page's markers and its collab doc's version
|
|
287
|
+
// together, so read them in one statement: two reads could straddle a
|
|
288
|
+
// commit and see the page behind its own live copy.
|
|
289
|
+
// guard:allow-unscoped — callers check access to the page first, and the gate returns only its write path.
|
|
290
|
+
const [markers] = await db
|
|
291
|
+
.select({
|
|
292
|
+
...liveBodyWriterPageColumns,
|
|
293
|
+
collabVersion: sql<unknown>`(SELECT collab.version FROM _collab_docs AS collab WHERE collab.doc_id = documents.id)`,
|
|
294
|
+
})
|
|
295
|
+
.from(schema.documents)
|
|
296
|
+
.where(eq(schema.documents.id, docId))
|
|
297
|
+
.limit(1);
|
|
298
|
+
return gateForMarkers(
|
|
299
|
+
markers,
|
|
300
|
+
async () => markers?.collabVersion,
|
|
189
301
|
context.kind === "transaction" ? { transaction: context.executor } : {},
|
|
190
302
|
);
|
|
191
303
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createBoundedTaskRunner,
|
|
3
|
+
defineLiveCopy,
|
|
4
|
+
} from "@agent-native/core/collab";
|
|
5
|
+
|
|
6
|
+
import { LIVE_BODY_FIELD } from "../../shared/live-body.js";
|
|
7
|
+
import { liveBodyWriterGate } from "../lib/live-body-writer.js";
|
|
8
|
+
import { persistContentLiveCopy } from "./persist.js";
|
|
9
|
+
import source from "./tasks.ts?bounded-task";
|
|
10
|
+
import { liveBodyConversionFor } from "./write-cause.js";
|
|
11
|
+
|
|
12
|
+
/** Edits `tasks.ts` defines, by name. */
|
|
13
|
+
export const CONTENT_LIVE_COPY_EDITS = {
|
|
14
|
+
replaceBody: "replace-body",
|
|
15
|
+
} as const;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Content's page bodies as live copies. A page is covered while the D2 gate
|
|
19
|
+
* says `live_copy`, and while it is being switched. Eligibility uses the
|
|
20
|
+
* request's flag snapshot; persist rechecks flags in its locked transaction.
|
|
21
|
+
*/
|
|
22
|
+
export const contentLiveCopy = defineLiveCopy({
|
|
23
|
+
name: "content-body",
|
|
24
|
+
appliesTo: async (documentId) => {
|
|
25
|
+
if (liveBodyConversionFor(documentId) !== undefined) return true;
|
|
26
|
+
return (await liveBodyWriterGate(documentId)).writePath === "live_copy";
|
|
27
|
+
},
|
|
28
|
+
runner: createBoundedTaskRunner({ source }),
|
|
29
|
+
field: LIVE_BODY_FIELD,
|
|
30
|
+
persist: persistContentLiveCopy,
|
|
31
|
+
});
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// `import source from "./tasks.ts?bounded-task"` is bundled at build time by
|
|
2
|
+
// core's bounded-task plugin into a source the worker runner evaluates.
|
|
3
|
+
declare module "*?bounded-task" {
|
|
4
|
+
const source: import("@agent-native/core/collab").BoundedTaskSource;
|
|
5
|
+
export default source;
|
|
6
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { drizzleOverExec } from "../db/exec.js";
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BoundedTaskError,
|
|
3
|
+
createBoundedTaskRunner,
|
|
4
|
+
type BoundedTaskErrorCode,
|
|
5
|
+
} from "@agent-native/core/collab";
|
|
6
|
+
|
|
7
|
+
import { CONTENT_HOLDS_BODY_TASK, type HoldsBodyResult } from "./task-names.js";
|
|
8
|
+
import source from "./tasks.ts?bounded-task";
|
|
9
|
+
|
|
10
|
+
// Its own worker: a save's comparison runs under the page lock, and on the
|
|
11
|
+
// live copy's worker a slow one would hold up every page's collab updates.
|
|
12
|
+
const runner = createBoundedTaskRunner({ source });
|
|
13
|
+
|
|
14
|
+
// Limits a body can hit. Anything else the worker reports is a bug here.
|
|
15
|
+
const UNCHECKED: ReadonlySet<BoundedTaskErrorCode> = new Set([
|
|
16
|
+
"unavailable",
|
|
17
|
+
"queue_full",
|
|
18
|
+
"queue_timeout",
|
|
19
|
+
"input_too_large",
|
|
20
|
+
"deadline_exceeded",
|
|
21
|
+
"memory_exceeded",
|
|
22
|
+
"worker_crashed",
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
const RESULTS: ReadonlySet<unknown> = new Set<HoldsBodyResult>([
|
|
26
|
+
"held",
|
|
27
|
+
"unheld",
|
|
28
|
+
"noncanonical",
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Whether a switched page's stored body holds every change a browser save
|
|
33
|
+
* made to the body it was authored on, with any other changes clear of them
|
|
34
|
+
* (`bodyHoldsChanges`). The save's text then reached the live copy over
|
|
35
|
+
* collab, and only a peer's commits differ. `noncanonical` when the save's
|
|
36
|
+
* body is not its own serialization, and `unchecked` when the comparison hit
|
|
37
|
+
* the worker's limits.
|
|
38
|
+
*/
|
|
39
|
+
export async function liveCopyHoldsSave(args: {
|
|
40
|
+
base: string;
|
|
41
|
+
stored: string;
|
|
42
|
+
candidate: string;
|
|
43
|
+
}): Promise<HoldsBodyResult | "unchecked"> {
|
|
44
|
+
let result: unknown;
|
|
45
|
+
try {
|
|
46
|
+
result = await runner.run<unknown>(CONTENT_HOLDS_BODY_TASK, args);
|
|
47
|
+
} catch (error) {
|
|
48
|
+
if (error instanceof BoundedTaskError && UNCHECKED.has(error.code)) {
|
|
49
|
+
return "unchecked";
|
|
50
|
+
}
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
if (!RESULTS.has(result)) {
|
|
54
|
+
throw new TypeError(
|
|
55
|
+
`${CONTENT_HOLDS_BODY_TASK} answered ${JSON.stringify(result)}`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
return result as HoldsBodyResult;
|
|
59
|
+
}
|