@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
|
@@ -0,0 +1,656 @@
|
|
|
1
|
+
import { ActionContractError } from "@agent-native/core";
|
|
2
|
+
import type {
|
|
3
|
+
LiveCopyCommit,
|
|
4
|
+
LiveCopyPersistOutcome,
|
|
5
|
+
} from "@agent-native/core/collab";
|
|
6
|
+
import type { DbExec } from "@agent-native/core/db";
|
|
7
|
+
import { and, desc, eq, isNull, or, sql } from "drizzle-orm";
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
lockPrimaryBlocksFields,
|
|
11
|
+
persistBlocksFieldIdentity,
|
|
12
|
+
} from "../../actions/_blocks-field-identity.js";
|
|
13
|
+
import { reconcileInlineDatabasesForDocumentWithDb } from "../../actions/_content-database-lifecycle.js";
|
|
14
|
+
import { documentContentHash } from "../../actions/_document-edit-mutation.js";
|
|
15
|
+
import { assertSourceAllowsPageEdit } from "../../actions/_document-source.js";
|
|
16
|
+
import { HISTORY_IDLE_MS } from "../../app/components/editor/document-history-session.js";
|
|
17
|
+
import { changedBlocksBetween } from "../../shared/document-intent-merge.js";
|
|
18
|
+
import { getDb, schema } from "../db/index.js";
|
|
19
|
+
import {
|
|
20
|
+
documentAttributionActor,
|
|
21
|
+
documentEditAttribution,
|
|
22
|
+
} from "../lib/document-attribution.js";
|
|
23
|
+
import { recordDocumentBodyIntent } from "../lib/document-body-intents.js";
|
|
24
|
+
import {
|
|
25
|
+
recordDocumentHistoryTransition,
|
|
26
|
+
type DocumentHistoryCause,
|
|
27
|
+
} from "../lib/document-history.js";
|
|
28
|
+
import { nextDocumentUpdatedAt } from "../lib/document-updated-at.js";
|
|
29
|
+
import {
|
|
30
|
+
liveBodyWriterFlagsOn,
|
|
31
|
+
liveBodyWriterGateForRow,
|
|
32
|
+
} from "../lib/live-body-writer.js";
|
|
33
|
+
import { syncPrivateCalloutReferences } from "../lib/private-icon-references.js";
|
|
34
|
+
import { drizzleOverExec } from "./exec-db.js";
|
|
35
|
+
import {
|
|
36
|
+
currentContentBodyWriteCause,
|
|
37
|
+
liveBodyConversionFor,
|
|
38
|
+
type ContentBodyIntentRecord,
|
|
39
|
+
type ContentBodyWriteCause,
|
|
40
|
+
type LiveBodyConversion,
|
|
41
|
+
} from "./write-cause.js";
|
|
42
|
+
|
|
43
|
+
type ContentDb = ReturnType<typeof getDb>;
|
|
44
|
+
|
|
45
|
+
/** What one live-copy commit wrote to its page, read back in the same transaction. */
|
|
46
|
+
export interface PersistedContentBodyCommit {
|
|
47
|
+
/** The page body before this commit. */
|
|
48
|
+
before: string;
|
|
49
|
+
/** The page body this commit wrote: the live copy's derived text. */
|
|
50
|
+
after: string;
|
|
51
|
+
bodyRevision: number;
|
|
52
|
+
updatedAt: string;
|
|
53
|
+
/** Inline collections the new body no longer holds, now in Trash. */
|
|
54
|
+
softDeletedDatabaseIds: string[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const persistedCommits = new WeakMap<DbExec, PersistedContentBodyCommit>();
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* What the binding's persist wrote in transaction `tx`, for a caller's own
|
|
61
|
+
* `mutateLiveCopy` persist, which core runs after it in the same
|
|
62
|
+
* transaction. `undefined` on an unchanged edit, which commits no live copy.
|
|
63
|
+
* A server write whose page left its live copy never gets this far: persist
|
|
64
|
+
* refuses it with `LIVE_BODY_WRITE_PATH_CHANGED`.
|
|
65
|
+
*/
|
|
66
|
+
export function persistedContentBodyCommit(
|
|
67
|
+
tx: DbExec,
|
|
68
|
+
): PersistedContentBodyCommit | undefined {
|
|
69
|
+
return persistedCommits.get(tx);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type LiveBodyConversionErrorCode = "base_changed" | "flag_off";
|
|
73
|
+
|
|
74
|
+
export class LiveBodyConversionError extends Error {
|
|
75
|
+
constructor(
|
|
76
|
+
readonly code: LiveBodyConversionErrorCode,
|
|
77
|
+
readonly documentId: string,
|
|
78
|
+
message: string,
|
|
79
|
+
) {
|
|
80
|
+
super(message);
|
|
81
|
+
this.name = "LiveBodyConversionError";
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// A typing session sends a commit every few hundred milliseconds. History
|
|
86
|
+
// keeps one row per this interval, and each commit inside it updates that
|
|
87
|
+
// row, so a session's last row always holds its final state. A row changes
|
|
88
|
+
// only within the interval after it was created.
|
|
89
|
+
const LIVE_COPY_HISTORY_INTERVAL_MS = 2_000;
|
|
90
|
+
|
|
91
|
+
const pageColumns = {
|
|
92
|
+
id: schema.documents.id,
|
|
93
|
+
title: schema.documents.title,
|
|
94
|
+
content: schema.documents.content,
|
|
95
|
+
bodyRevision: schema.documents.bodyRevision,
|
|
96
|
+
collabBodyRevision: schema.documents.collabBodyRevision,
|
|
97
|
+
liveBodySwitchedAt: schema.documents.liveBodySwitchedAt,
|
|
98
|
+
liveCopyVersion: schema.documents.liveCopyVersion,
|
|
99
|
+
ownerEmail: schema.documents.ownerEmail,
|
|
100
|
+
orgId: schema.documents.orgId,
|
|
101
|
+
updatedAt: schema.documents.updatedAt,
|
|
102
|
+
trashedAt: schema.documents.trashedAt,
|
|
103
|
+
sourceMode: schema.documents.sourceMode,
|
|
104
|
+
sourceRootPath: schema.documents.sourceRootPath,
|
|
105
|
+
sourcePath: schema.documents.sourcePath,
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export type PageRow = {
|
|
109
|
+
id: string;
|
|
110
|
+
title: string;
|
|
111
|
+
content: string;
|
|
112
|
+
bodyRevision: number;
|
|
113
|
+
collabBodyRevision: number | null;
|
|
114
|
+
liveBodySwitchedAt: string | null;
|
|
115
|
+
liveCopyVersion: number | null;
|
|
116
|
+
ownerEmail: string;
|
|
117
|
+
orgId: string | null;
|
|
118
|
+
updatedAt: string;
|
|
119
|
+
trashedAt: string | null;
|
|
120
|
+
sourceMode: string | null;
|
|
121
|
+
sourceRootPath: string | null;
|
|
122
|
+
sourcePath: string | null;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
type BlocksFields = Awaited<ReturnType<typeof lockPrimaryBlocksFields>>;
|
|
126
|
+
|
|
127
|
+
export interface LockedPage {
|
|
128
|
+
page: PageRow;
|
|
129
|
+
blocksFields: BlocksFields;
|
|
130
|
+
lockedDatabaseIds: ReadonlySet<string>;
|
|
131
|
+
lockedMembershipIds: ReadonlySet<string>;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function scopeChanged(message: string): never {
|
|
135
|
+
throw new ActionContractError(message, {
|
|
136
|
+
errorCode: "DOCUMENT_METADATA_SCOPE_CHANGED",
|
|
137
|
+
statusCode: 409,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Locks a page for a body write in Content's row-lock order: the collection
|
|
143
|
+
* it backs and the inline collections it owns, in one id-ordered pass; then
|
|
144
|
+
* its memberships; then the page row. Core takes `_collab_docs` after all of
|
|
145
|
+
* them. Undefined when the page does not exist.
|
|
146
|
+
*/
|
|
147
|
+
async function lockPageForBodyWrite(
|
|
148
|
+
db: ContentDb,
|
|
149
|
+
documentId: string,
|
|
150
|
+
): Promise<LockedPage | undefined> {
|
|
151
|
+
// guard:allow-unscoped — reads the owner of a page whose write the collab route or the calling action already authorized.
|
|
152
|
+
const [owner] = await db
|
|
153
|
+
.select({ ownerEmail: schema.documents.ownerEmail })
|
|
154
|
+
.from(schema.documents)
|
|
155
|
+
.where(eq(schema.documents.id, documentId));
|
|
156
|
+
if (!owner) return undefined;
|
|
157
|
+
const databases = await db
|
|
158
|
+
.select({
|
|
159
|
+
id: schema.contentDatabases.id,
|
|
160
|
+
documentId: schema.contentDatabases.documentId,
|
|
161
|
+
})
|
|
162
|
+
.from(schema.contentDatabases)
|
|
163
|
+
.innerJoin(
|
|
164
|
+
schema.documents,
|
|
165
|
+
eq(schema.documents.id, schema.contentDatabases.documentId),
|
|
166
|
+
)
|
|
167
|
+
.where(
|
|
168
|
+
or(
|
|
169
|
+
and(
|
|
170
|
+
eq(schema.documents.id, documentId),
|
|
171
|
+
eq(schema.documents.ownerEmail, owner.ownerEmail),
|
|
172
|
+
),
|
|
173
|
+
and(
|
|
174
|
+
eq(schema.contentDatabases.ownerDocumentId, documentId),
|
|
175
|
+
eq(schema.contentDatabases.ownerEmail, owner.ownerEmail),
|
|
176
|
+
isNull(schema.contentDatabases.deletedAt),
|
|
177
|
+
),
|
|
178
|
+
),
|
|
179
|
+
)
|
|
180
|
+
.orderBy(sql`${schema.contentDatabases.id} COLLATE "C"`)
|
|
181
|
+
.for("update", { of: schema.contentDatabases });
|
|
182
|
+
if (databases.filter((row) => row.documentId === documentId).length > 1) {
|
|
183
|
+
scopeChanged(
|
|
184
|
+
"The document's backing collection changed. Read the document again and retry.",
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
// Read before the lock, so a membership added in between is refused later
|
|
188
|
+
// rather than written without its lock.
|
|
189
|
+
const memberships = await pageMembershipIds(db, documentId);
|
|
190
|
+
const blocksFields = await lockPrimaryBlocksFields(db, documentId);
|
|
191
|
+
// guard:allow-unscoped — locks the page row whose owner was read above, for a write already authorized.
|
|
192
|
+
const [page] = await db
|
|
193
|
+
.select(pageColumns)
|
|
194
|
+
.from(schema.documents)
|
|
195
|
+
.where(eq(schema.documents.id, documentId))
|
|
196
|
+
.for("update");
|
|
197
|
+
if (!page) return undefined;
|
|
198
|
+
if (page.ownerEmail !== owner.ownerEmail) {
|
|
199
|
+
scopeChanged(
|
|
200
|
+
`Page ${documentId} changed owner while its body was being written. Read it again and retry.`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
page: page as PageRow,
|
|
205
|
+
blocksFields,
|
|
206
|
+
lockedDatabaseIds: new Set(databases.map((row) => row.id)),
|
|
207
|
+
lockedMembershipIds: new Set(memberships),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function pageMembershipIds(
|
|
212
|
+
db: ContentDb,
|
|
213
|
+
documentId: string,
|
|
214
|
+
): Promise<string[]> {
|
|
215
|
+
// guard:allow-unscoped — lists the memberships of a page whose write was already authorized.
|
|
216
|
+
const rows = await db
|
|
217
|
+
.select({ id: schema.contentDatabaseItems.id })
|
|
218
|
+
.from(schema.contentDatabaseItems)
|
|
219
|
+
.where(eq(schema.contentDatabaseItems.documentId, documentId));
|
|
220
|
+
return rows.map((row) => row.id);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The blocks-field write covers the memberships locked before the page. One
|
|
225
|
+
* added since would get no write, and locking it now would take it after the
|
|
226
|
+
* page, against an edit or restore that locks memberships first.
|
|
227
|
+
*/
|
|
228
|
+
async function assertMembershipsLocked(
|
|
229
|
+
db: ContentDb,
|
|
230
|
+
documentId: string,
|
|
231
|
+
lockedMembershipIds: ReadonlySet<string>,
|
|
232
|
+
): Promise<void> {
|
|
233
|
+
const current = await pageMembershipIds(db, documentId);
|
|
234
|
+
if (current.some((id) => !lockedMembershipIds.has(id))) {
|
|
235
|
+
scopeChanged(
|
|
236
|
+
"The document was added to a collection while it saved. Read the document again and retry.",
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Refuses a server write whose page left its live copy after the write
|
|
243
|
+
* chose it. A commit persist leaves alone would put the edit in Yjs and not
|
|
244
|
+
* in SQL; the writer can take its legacy path instead.
|
|
245
|
+
*/
|
|
246
|
+
function writePathChanged(documentId: string, reason: string): never {
|
|
247
|
+
throw new ActionContractError(
|
|
248
|
+
`Page ${documentId} stopped taking writes through its live copy (${reason}) while this write was in flight. Read it again and retry.`,
|
|
249
|
+
{
|
|
250
|
+
errorCode: "LIVE_BODY_WRITE_PATH_CHANGED",
|
|
251
|
+
statusCode: 409,
|
|
252
|
+
details: { documentId, reason },
|
|
253
|
+
},
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Live-copy commits bypass update-document's mutation checks.
|
|
258
|
+
function assertEditablePage(page: PageRow): void {
|
|
259
|
+
if (page.trashedAt) {
|
|
260
|
+
throw new ActionContractError("Document not found.", {
|
|
261
|
+
errorCode: "DOCUMENT_NOT_FOUND",
|
|
262
|
+
statusCode: 404,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
assertSourceAllowsPageEdit(page);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Locks a page in Content's order, as persist locks it, for a write that runs
|
|
270
|
+
* in a live-copy transaction without a body commit: mutateLiveCopy's own
|
|
271
|
+
* `persist` on an edit that changed nothing. Pass that persist's `tx`; a
|
|
272
|
+
* `getDb()` handle is another connection, outside the commit's locks.
|
|
273
|
+
* Refuses a page in Trash; undefined when the page does not exist.
|
|
274
|
+
*/
|
|
275
|
+
export async function lockLiveCopyPage(
|
|
276
|
+
tx: DbExec,
|
|
277
|
+
documentId: string,
|
|
278
|
+
): Promise<LockedPage | undefined> {
|
|
279
|
+
const locked = await lockPageForBodyWrite(drizzleOverExec(tx), documentId);
|
|
280
|
+
if (locked) assertEditablePage(locked.page);
|
|
281
|
+
return locked;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
type EditorHistory =
|
|
285
|
+
| { kind: "record"; cause: DocumentHistoryCause }
|
|
286
|
+
| { kind: "update"; versionId: string };
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* History for a commit no server writer caused: the editor's own updates.
|
|
290
|
+
* They join the actor's open session, one row per interval.
|
|
291
|
+
*/
|
|
292
|
+
async function editorHistory(
|
|
293
|
+
db: ContentDb,
|
|
294
|
+
page: PageRow,
|
|
295
|
+
documentId: string,
|
|
296
|
+
actorEmail: string | null,
|
|
297
|
+
now: string,
|
|
298
|
+
): Promise<EditorHistory> {
|
|
299
|
+
const [latest] = await db
|
|
300
|
+
.select({
|
|
301
|
+
id: schema.documentVersions.id,
|
|
302
|
+
groupId: schema.documentVersions.groupId,
|
|
303
|
+
groupKind: schema.documentVersions.groupKind,
|
|
304
|
+
checkpointKind: schema.documentVersions.checkpointKind,
|
|
305
|
+
actorEmail: schema.documentVersions.actorEmail,
|
|
306
|
+
createdAt: schema.documentVersions.createdAt,
|
|
307
|
+
updatedAt: schema.documentVersions.updatedAt,
|
|
308
|
+
})
|
|
309
|
+
.from(schema.documentVersions)
|
|
310
|
+
.where(
|
|
311
|
+
and(
|
|
312
|
+
eq(schema.documentVersions.ownerEmail, page.ownerEmail),
|
|
313
|
+
eq(schema.documentVersions.documentId, documentId),
|
|
314
|
+
),
|
|
315
|
+
)
|
|
316
|
+
.orderBy(
|
|
317
|
+
desc(schema.documentVersions.createdAt),
|
|
318
|
+
sql`case when ${schema.documentVersions.checkpointKind} = 'after' then 0 else 1 end`,
|
|
319
|
+
desc(schema.documentVersions.id),
|
|
320
|
+
)
|
|
321
|
+
.limit(1);
|
|
322
|
+
const base = {
|
|
323
|
+
actorEmail,
|
|
324
|
+
actorKind: "human" as const,
|
|
325
|
+
origin: "collab",
|
|
326
|
+
operation: "live-copy-update",
|
|
327
|
+
};
|
|
328
|
+
if (
|
|
329
|
+
latest?.groupId &&
|
|
330
|
+
latest.groupKind === "human_session" &&
|
|
331
|
+
latest.actorEmail === actorEmail &&
|
|
332
|
+
Date.parse(now) - Date.parse(latest.updatedAt ?? latest.createdAt) <
|
|
333
|
+
HISTORY_IDLE_MS
|
|
334
|
+
) {
|
|
335
|
+
if (
|
|
336
|
+
latest.checkpointKind === "after" &&
|
|
337
|
+
Date.parse(now) - Date.parse(latest.createdAt) <
|
|
338
|
+
LIVE_COPY_HISTORY_INTERVAL_MS
|
|
339
|
+
) {
|
|
340
|
+
return { kind: "update", versionId: latest.id };
|
|
341
|
+
}
|
|
342
|
+
return {
|
|
343
|
+
kind: "record",
|
|
344
|
+
cause: {
|
|
345
|
+
...base,
|
|
346
|
+
groupId: latest.groupId,
|
|
347
|
+
groupKind: "human_session",
|
|
348
|
+
skipBeforeCheckpoint: true,
|
|
349
|
+
},
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
return {
|
|
353
|
+
kind: "record",
|
|
354
|
+
cause: {
|
|
355
|
+
...base,
|
|
356
|
+
groupId: `human:${actorEmail ?? "unknown"}:${crypto.randomUUID()}`,
|
|
357
|
+
groupKind: "human_session",
|
|
358
|
+
},
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function serverHistoryCause(
|
|
363
|
+
cause: ContentBodyWriteCause,
|
|
364
|
+
actorEmail: string | null,
|
|
365
|
+
): DocumentHistoryCause {
|
|
366
|
+
return {
|
|
367
|
+
ctx: cause.ctx,
|
|
368
|
+
// Without an action context, SQL attribution takes the request's user;
|
|
369
|
+
// History must name the same actor.
|
|
370
|
+
...(cause.ctx ? {} : { actorEmail }),
|
|
371
|
+
operation: cause.operation,
|
|
372
|
+
...(cause.origin ? { origin: cause.origin } : {}),
|
|
373
|
+
...(cause.historySessionId
|
|
374
|
+
? { historySessionId: cause.historySessionId }
|
|
375
|
+
: {}),
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function recordBodyIntent(
|
|
380
|
+
db: ContentDb,
|
|
381
|
+
documentId: string,
|
|
382
|
+
page: PageRow,
|
|
383
|
+
record: ContentBodyIntentRecord,
|
|
384
|
+
commit: { after: string; committedRevision: number; now: string },
|
|
385
|
+
): Promise<void> {
|
|
386
|
+
await recordDocumentBodyIntent({
|
|
387
|
+
db,
|
|
388
|
+
ownerEmail: page.ownerEmail,
|
|
389
|
+
orgId: page.orgId ?? "",
|
|
390
|
+
documentId,
|
|
391
|
+
intent: {
|
|
392
|
+
...record.intent,
|
|
393
|
+
authoredBaseRevision:
|
|
394
|
+
record.intent.authoredBaseRevision ?? page.bodyRevision,
|
|
395
|
+
},
|
|
396
|
+
candidateHash: record.candidateHash ?? documentContentHash(commit.after),
|
|
397
|
+
metadataHash: record.metadataHash,
|
|
398
|
+
changedBlockIndexes:
|
|
399
|
+
record.changedBlockIndexes ??
|
|
400
|
+
changedBlocksBetween(page.content, commit.after),
|
|
401
|
+
displacedCheckpointId: record.displacedCheckpointId,
|
|
402
|
+
committedRevision: commit.committedRevision,
|
|
403
|
+
canonicalChanged: commit.after !== page.content,
|
|
404
|
+
now: commit.now,
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Writes `text` as the page body with everything a body write keeps in step:
|
|
410
|
+
* revision, attribution, blocks-field identity, callout references, History,
|
|
411
|
+
* body intent and inline collections. The caller holds the page lock.
|
|
412
|
+
*/
|
|
413
|
+
async function writeBody(args: {
|
|
414
|
+
tx: DbExec;
|
|
415
|
+
db: ContentDb;
|
|
416
|
+
documentId: string;
|
|
417
|
+
locked: LockedPage;
|
|
418
|
+
text: string;
|
|
419
|
+
/** The `_collab_docs.version` this commit leaves stored. */
|
|
420
|
+
liveCopyVersion: number;
|
|
421
|
+
cause: ContentBodyWriteCause | undefined;
|
|
422
|
+
extraColumns?: Partial<typeof schema.documents.$inferInsert>;
|
|
423
|
+
}): Promise<PersistedContentBodyCommit> {
|
|
424
|
+
const { db, documentId, text, cause } = args;
|
|
425
|
+
const { page, blocksFields, lockedDatabaseIds, lockedMembershipIds } =
|
|
426
|
+
args.locked;
|
|
427
|
+
const changed = text !== page.content;
|
|
428
|
+
if (changed) {
|
|
429
|
+
await assertMembershipsLocked(db, documentId, lockedMembershipIds);
|
|
430
|
+
}
|
|
431
|
+
const bodyRevision = page.bodyRevision + (changed ? 1 : 0);
|
|
432
|
+
const updatedAt = changed
|
|
433
|
+
? nextDocumentUpdatedAt(page.updatedAt)
|
|
434
|
+
: page.updatedAt;
|
|
435
|
+
const actor = documentAttributionActor(cause?.ctx);
|
|
436
|
+
// guard:allow-unscoped — writes the page row this transaction locked, for a write already authorized.
|
|
437
|
+
await db
|
|
438
|
+
.update(schema.documents)
|
|
439
|
+
.set({
|
|
440
|
+
...args.extraColumns,
|
|
441
|
+
collabBodyRevision: bodyRevision,
|
|
442
|
+
liveCopyVersion: args.liveCopyVersion,
|
|
443
|
+
...(changed
|
|
444
|
+
? {
|
|
445
|
+
content: text,
|
|
446
|
+
bodyRevision,
|
|
447
|
+
updatedAt,
|
|
448
|
+
...(actor ? documentEditAttribution(actor) : {}),
|
|
449
|
+
}
|
|
450
|
+
: {}),
|
|
451
|
+
})
|
|
452
|
+
.where(eq(schema.documents.id, documentId));
|
|
453
|
+
|
|
454
|
+
let softDeletedDatabaseIds: string[] = [];
|
|
455
|
+
if (changed) {
|
|
456
|
+
for (const field of blocksFields) {
|
|
457
|
+
await persistBlocksFieldIdentity({
|
|
458
|
+
db,
|
|
459
|
+
ownerEmail: field.ownerEmail,
|
|
460
|
+
documentId,
|
|
461
|
+
propertyId: field.propertyId,
|
|
462
|
+
previousMarkdown: page.content,
|
|
463
|
+
markdown: text,
|
|
464
|
+
now: updatedAt,
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
await syncPrivateCalloutReferences(db, {
|
|
468
|
+
documentId,
|
|
469
|
+
before: page.content,
|
|
470
|
+
after: text,
|
|
471
|
+
userEmail: actor ?? page.ownerEmail,
|
|
472
|
+
ownerEmail: page.ownerEmail,
|
|
473
|
+
orgId: page.orgId,
|
|
474
|
+
});
|
|
475
|
+
const history: EditorHistory = cause
|
|
476
|
+
? { kind: "record", cause: serverHistoryCause(cause, actor) }
|
|
477
|
+
: await editorHistory(db, page, documentId, actor, updatedAt);
|
|
478
|
+
if (history.kind === "record") {
|
|
479
|
+
await recordDocumentHistoryTransition({
|
|
480
|
+
db,
|
|
481
|
+
ownerEmail: page.ownerEmail,
|
|
482
|
+
documentId,
|
|
483
|
+
before: { title: page.title, content: page.content },
|
|
484
|
+
after: { title: page.title, content: text },
|
|
485
|
+
beforeBodyRevision: page.bodyRevision,
|
|
486
|
+
afterBodyRevision: bodyRevision,
|
|
487
|
+
cause: history.cause,
|
|
488
|
+
now: updatedAt,
|
|
489
|
+
});
|
|
490
|
+
} else {
|
|
491
|
+
await db
|
|
492
|
+
.update(schema.documentVersions)
|
|
493
|
+
.set({
|
|
494
|
+
title: page.title,
|
|
495
|
+
content: text,
|
|
496
|
+
bodyRevision,
|
|
497
|
+
updatedAt,
|
|
498
|
+
})
|
|
499
|
+
.where(
|
|
500
|
+
and(
|
|
501
|
+
eq(schema.documentVersions.id, history.versionId),
|
|
502
|
+
eq(schema.documentVersions.ownerEmail, page.ownerEmail),
|
|
503
|
+
eq(schema.documentVersions.documentId, documentId),
|
|
504
|
+
),
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
softDeletedDatabaseIds = await reconcileInlineDatabasesForDocumentWithDb({
|
|
508
|
+
db,
|
|
509
|
+
documentId,
|
|
510
|
+
content: text,
|
|
511
|
+
ownerEmail: page.ownerEmail,
|
|
512
|
+
now: updatedAt,
|
|
513
|
+
lockedDatabaseIds,
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
if (cause?.intent) {
|
|
517
|
+
await recordBodyIntent(db, documentId, page, cause.intent, {
|
|
518
|
+
after: text,
|
|
519
|
+
committedRevision: bodyRevision,
|
|
520
|
+
now: updatedAt,
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
const persisted = {
|
|
524
|
+
before: page.content,
|
|
525
|
+
after: text,
|
|
526
|
+
bodyRevision,
|
|
527
|
+
updatedAt,
|
|
528
|
+
softDeletedDatabaseIds,
|
|
529
|
+
};
|
|
530
|
+
persistedCommits.set(args.tx, persisted);
|
|
531
|
+
return persisted;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Switches a page whose live copy now holds `text`. Runs in the transaction
|
|
536
|
+
* that committed the copy, or, when the copy already held the body, in the
|
|
537
|
+
* one core opens for the conversion's own persist.
|
|
538
|
+
*/
|
|
539
|
+
export async function persistLiveBodyConversion(
|
|
540
|
+
tx: DbExec,
|
|
541
|
+
conversion: LiveBodyConversion,
|
|
542
|
+
text: string,
|
|
543
|
+
liveCopyVersion: number,
|
|
544
|
+
): Promise<PersistedContentBodyCommit> {
|
|
545
|
+
const db = drizzleOverExec(tx);
|
|
546
|
+
const locked = await lockPageForBodyWrite(db, conversion.documentId);
|
|
547
|
+
const page = locked?.page;
|
|
548
|
+
if (!locked || !page || page.bodyRevision !== conversion.baseRevision) {
|
|
549
|
+
throw new LiveBodyConversionError(
|
|
550
|
+
"base_changed",
|
|
551
|
+
conversion.documentId,
|
|
552
|
+
`Page ${conversion.documentId} changed while it was being switched to its live copy.`,
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
if (!(await liveBodyWriterFlagsOn(page, { transaction: tx }))) {
|
|
556
|
+
throw new LiveBodyConversionError(
|
|
557
|
+
"flag_off",
|
|
558
|
+
conversion.documentId,
|
|
559
|
+
`The live-copy writer is off for page ${conversion.documentId}'s owner.`,
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
assertEditablePage(page);
|
|
563
|
+
return writeBody({
|
|
564
|
+
tx,
|
|
565
|
+
db,
|
|
566
|
+
documentId: conversion.documentId,
|
|
567
|
+
locked,
|
|
568
|
+
text,
|
|
569
|
+
liveCopyVersion,
|
|
570
|
+
cause: currentContentBodyWriteCause(),
|
|
571
|
+
extraColumns: { liveBodySwitchedAt: new Date().toISOString() },
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* The binding's persist: derives the page from its live copy in the commit's
|
|
577
|
+
* transaction. It rechecks the gate under the page lock and leaves a page
|
|
578
|
+
* that went back to its legacy writer alone.
|
|
579
|
+
*/
|
|
580
|
+
export async function persistContentLiveCopy(
|
|
581
|
+
tx: DbExec,
|
|
582
|
+
commit: LiveCopyCommit,
|
|
583
|
+
): Promise<LiveCopyPersistOutcome> {
|
|
584
|
+
const conversion = liveBodyConversionFor(commit.docId);
|
|
585
|
+
if (conversion) {
|
|
586
|
+
await persistLiveBodyConversion(
|
|
587
|
+
tx,
|
|
588
|
+
conversion,
|
|
589
|
+
commit.text,
|
|
590
|
+
commit.version,
|
|
591
|
+
);
|
|
592
|
+
return { derived: true };
|
|
593
|
+
}
|
|
594
|
+
const cause = currentContentBodyWriteCause();
|
|
595
|
+
if (!cause && commit.origin !== "update") {
|
|
596
|
+
throw new Error(
|
|
597
|
+
`A server write to page ${commit.docId}'s live copy has no cause; wrap it in withContentBodyWriteCause.`,
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
const db = drizzleOverExec(tx);
|
|
601
|
+
const locked = await lockPageForBodyWrite(db, commit.docId);
|
|
602
|
+
const gate = await liveBodyWriterGateForRow(locked?.page, {
|
|
603
|
+
transaction: tx,
|
|
604
|
+
});
|
|
605
|
+
if (!locked || gate.writePath === "legacy") {
|
|
606
|
+
const reason = gate.writePath === "legacy" ? gate.reason : "no_page";
|
|
607
|
+
if (cause) writePathChanged(commit.docId, reason);
|
|
608
|
+
// An editor update still commits, as on main. It moves the live copy
|
|
609
|
+
// past `live_copy_version`, so the gate keeps the page legacy until it
|
|
610
|
+
// is converted again.
|
|
611
|
+
return { derived: false, reason };
|
|
612
|
+
}
|
|
613
|
+
assertEditablePage(locked.page);
|
|
614
|
+
await writeBody({
|
|
615
|
+
tx,
|
|
616
|
+
db,
|
|
617
|
+
documentId: commit.docId,
|
|
618
|
+
locked,
|
|
619
|
+
text: commit.text,
|
|
620
|
+
liveCopyVersion: commit.version,
|
|
621
|
+
cause,
|
|
622
|
+
});
|
|
623
|
+
return { derived: true };
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* The persist an edit that changed nothing gets, which core runs in place of
|
|
628
|
+
* the binding's: it locks the page in Content's order and records the
|
|
629
|
+
* cause's intent at the current revision, as a legacy save records an
|
|
630
|
+
* accepted no-op.
|
|
631
|
+
*/
|
|
632
|
+
export async function persistUnchangedContentBodyEdit(
|
|
633
|
+
tx: DbExec,
|
|
634
|
+
documentId: string,
|
|
635
|
+
cause: ContentBodyWriteCause,
|
|
636
|
+
): Promise<void> {
|
|
637
|
+
const db = drizzleOverExec(tx);
|
|
638
|
+
const locked = await lockPageForBodyWrite(db, documentId);
|
|
639
|
+
const gate = await liveBodyWriterGateForRow(locked?.page, {
|
|
640
|
+
transaction: tx,
|
|
641
|
+
});
|
|
642
|
+
if (!locked || gate.writePath === "legacy") {
|
|
643
|
+
writePathChanged(
|
|
644
|
+
documentId,
|
|
645
|
+
gate.writePath === "legacy" ? gate.reason : "no_page",
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
assertEditablePage(locked.page);
|
|
649
|
+
if (cause.intent) {
|
|
650
|
+
await recordBodyIntent(db, documentId, locked.page, cause.intent, {
|
|
651
|
+
after: locked.page.content,
|
|
652
|
+
committedRevision: locked.page.bodyRevision,
|
|
653
|
+
now: new Date().toISOString(),
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
}
|