@agent-native/core 0.136.5 → 0.137.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/clips/app/components/import-menu.tsx +109 -0
- package/corpus/templates/clips/app/components/library/library-grid.tsx +8 -18
- package/corpus/templates/clips/app/components/library/library-layout.tsx +14 -50
- package/corpus/templates/clips/app/components/library/sort-menu.tsx +20 -6
- package/corpus/templates/clips/app/components/recorder/pre-record-panel.tsx +10 -34
- package/corpus/templates/clips/app/routes/_app.library._index.tsx +0 -2
- package/corpus/templates/clips/changelog/2026-08-04-cleaner-library-controls-with-an-icon-only-sort-button.md +6 -0
- package/corpus/templates/clips/changelog/2026-08-04-upload-and-loom-imports-now-live-under-one-import-menu.md +6 -0
- package/corpus/templates/content/actions/_database-utils.ts +6 -0
- package/corpus/templates/content/actions/bind-content-database-source-field.ts +273 -97
- package/corpus/templates/content/actions/change-content-database-source-role.ts +65 -29
- package/corpus/templates/content/actions/configure-document-property.ts +113 -32
- package/corpus/templates/content/actions/delete-content-database.ts +5 -7
- package/corpus/templates/content/actions/delete-document-property.ts +109 -62
- package/corpus/templates/content/actions/delete-document.ts +178 -36
- package/corpus/templates/content/actions/duplicate-database-item.ts +27 -0
- package/corpus/templates/content/actions/duplicate-database-items.ts +31 -1
- package/corpus/templates/content/actions/permanently-delete-document.ts +4 -6
- package/corpus/templates/content/actions/remove-database-items.ts +13 -0
- package/corpus/templates/content/actions/set-document-property.ts +69 -1
- package/corpus/templates/content/actions/upsert-database-item-by-key.ts +814 -0
- package/corpus/templates/content/parity/matrix.md +1 -1
- package/corpus/templates/content/parity/matrix.ts +1 -0
- package/corpus/templates/content/server/db/schema.ts +27 -0
- package/corpus/templates/content/server/plugins/db.ts +24 -0
- package/corpus/templates/design/app/components/design/BreakpointBar.tsx +122 -114
- package/corpus/templates/design/app/components/design/CanvasContextMenu.tsx +2 -2
- package/corpus/templates/design/app/components/design/EditPanel.tsx +68 -34
- package/corpus/templates/design/app/components/design/KeyboardShortcutsPanel.tsx +78 -4
- package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +8 -0
- package/corpus/templates/design/app/components/design/QuestionFlow.tsx +5 -2
- package/corpus/templates/design/app/components/design/edit-panel/panel-primitives.tsx +10 -9
- package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +31 -69
- package/corpus/templates/design/app/components/design/inspector/design-icons.tsx +80 -106
- package/corpus/templates/design/app/components/design/multi-screen/types.ts +4 -0
- package/corpus/templates/design/app/hooks/use-editor-preferences.ts +75 -0
- package/corpus/templates/design/app/i18n-data.ts +8 -0
- package/corpus/templates/design/app/i18n-keyboard-shortcuts.ts +61 -0
- package/corpus/templates/design/app/pages/design-editor/editor-preferences.ts +98 -0
- package/corpus/templates/design/app/pages/design-editor/nudge-intent.ts +491 -0
- package/corpus/templates/design/app/pages/design-editor/paste-placement.ts +133 -0
- package/corpus/templates/design/app/pages/design-editor/png-export-render.ts +8 -0
- package/corpus/templates/design/app/pages/design-editor/selection-state.ts +5 -12
- package/corpus/templates/design/changelog/2026-08-03-arrow-keys-now-reorder-a-layer-inside-an-auto-layout-frame.md +6 -0
- package/corpus/templates/design/changelog/2026-08-03-auto-layout-flow-icons-show-the-direction-at-a-glance-instea.md +6 -0
- package/corpus/templates/design/changelog/2026-08-03-auto-layout-icons-share-one-outlined-square-style-so-flow-ga.md +6 -0
- package/corpus/templates/design/changelog/2026-08-03-export-has-a-named-preview-section-you-can-expand-replacing-.md +6 -0
- package/corpus/templates/design/changelog/2026-08-03-export-preview-shows-the-selected-element-instead-of-the-who.md +6 -0
- package/corpus/templates/design/changelog/2026-08-03-inspector-sections-show-an-expand-indicator-so-it-is-clear-w.md +6 -0
- package/corpus/templates/design/changelog/2026-08-03-paste-lands-inside-a-selected-frame-instead-of-beside-it.md +6 -0
- package/corpus/templates/design/changelog/2026-08-03-the-add-breakpoint-button-sits-outside-the-breakpoint-select.md +6 -0
- package/corpus/templates/design/changelog/2026-08-03-the-agent-s-follow-up-questions-keep-space-below-the-continu.md +6 -0
- package/corpus/templates/design/changelog/2026-08-03-the-agent-s-follow-up-questions-share-the-chat-panel-s-backg.md +6 -0
- package/corpus/templates/design/changelog/2026-08-03-the-export-panel-no-longer-stacks-a-duplicate-export-block-o.md +6 -0
- package/corpus/templates/design/changelog/2026-08-03-the-text-tool-uses-a-t-icon-matching-the-letter-it-inserts.md +6 -0
- package/corpus/templates/factory/.env.example +17 -0
- package/corpus/templates/factory/AGENTS.md +27 -17
- package/corpus/templates/factory/README.md +19 -9
- package/corpus/templates/factory/actions/get-slack-feedback-context.ts +90 -0
- package/corpus/templates/factory/actions/get-triage-config.ts +14 -3
- package/corpus/templates/factory/actions/govern-agent-native-pull-request.ts +386 -0
- package/corpus/templates/factory/actions/list-factory-automations.ts +53 -0
- package/corpus/templates/factory/actions/list-triage-items.ts +7 -2
- package/corpus/templates/factory/actions/poll-github-sources.ts +203 -0
- package/corpus/templates/factory/actions/poll-sentry-errors.ts +161 -0
- package/corpus/templates/factory/actions/poll-slack-channel.ts +4 -2
- package/corpus/templates/factory/actions/run-factory-automation.ts +42 -0
- package/corpus/templates/factory/actions/save-factory-automation.ts +69 -0
- package/corpus/templates/factory/actions/save-triage-config.ts +28 -2
- package/corpus/templates/factory/actions/start-builder-for-item.ts +411 -0
- package/corpus/templates/factory/app/components/factory/FactoryCanvas.tsx +14 -3
- package/corpus/templates/factory/app/components/ui/checkbox.tsx +1 -0
- package/corpus/templates/factory/app/hooks/use-navigation-state.ts +7 -0
- package/corpus/templates/factory/app/i18n/en-US.ts +35 -1
- package/corpus/templates/factory/app/routes/factory.tsx +462 -9
- package/corpus/templates/factory/changelog/2026-08-04-added-organization-owned-factory-automations-for-slack-bug-t.md +6 -0
- package/corpus/templates/factory/changelog/2026-08-04-automation-editor-controls-and-model-picker.md +6 -0
- package/corpus/templates/factory/netlify.toml +2 -2
- package/corpus/templates/factory/server/connectors/credentials.ts +6 -5
- package/corpus/templates/factory/server/connectors/slack.ts +133 -5
- package/corpus/templates/factory/server/db/schema.ts +6 -0
- package/corpus/templates/factory/server/lib/require-factory-automation.ts +65 -0
- package/corpus/templates/factory/server/plugins/agent-chat.ts +29 -11
- package/corpus/templates/factory/server/plugins/auth.ts +1 -0
- package/corpus/templates/factory/server/plugins/factory-migrations.ts +12 -0
- package/corpus/templates/factory/server/plugins/factory-scheduler-job.ts +218 -67
- package/corpus/templates/factory/server/triage/builder-executor.ts +4 -0
- package/corpus/templates/factory/server/triage/contracts.ts +14 -1
- package/corpus/templates/factory/server/triage/github-client.ts +404 -0
- package/corpus/templates/factory/server/triage/metadata.ts +44 -0
- package/corpus/templates/factory/server/triage/pr-babysit.ts +5 -5
- package/corpus/templates/factory/server/triage/pr-policy.ts +144 -0
- package/corpus/templates/factory/server/triage/sentry-client.ts +180 -0
- package/corpus/templates/factory/server/triage/slack-client.ts +43 -0
- package/corpus/templates/factory/server/triage/slack-poller.ts +9 -2
- package/corpus/templates/plan/.agents/skills/visual-plan/SKILL.md +27 -0
- package/corpus/templates/plan/.agents/skills/visual-plan/references/canvas.md +14 -4
- package/corpus/templates/plan/.agents/skills/visual-plan/references/wireframe.md +7 -0
- package/corpus/templates/plan/.agents/skills/visual-recap/references/wireframe.md +7 -0
- package/corpus/templates/plan/actions/create-plan-design.ts +8 -6
- package/corpus/templates/plan/actions/create-prototype-plan.ts +8 -6
- package/corpus/templates/plan/actions/create-ui-plan.ts +7 -7
- package/corpus/templates/plan/actions/get-plan-blocks.ts +7 -1
- package/corpus/templates/plan/actions/validate-plan-input.ts +39 -0
- package/corpus/templates/plan/app/components/plan/CanvasArea.tsx +167 -11
- package/corpus/templates/plan/app/i18n/en-US.ts +1 -0
- package/corpus/templates/plan/changelog/2026-08-04-canvas-zoom-controls-now-explain-the-command-ctrl-plus-scrol.md +6 -0
- package/corpus/templates/plan/changelog/2026-08-04-pinch-to-zoom-is-now-supported-on-touchscreens-in-visual-pla.md +6 -0
- package/corpus/templates/plan/shared/plan-content.ts +17 -2
- package/dist/cli/skills-content/canvas.d.ts +3 -3
- package/dist/cli/skills-content/canvas.js +16 -5
- package/dist/cli/skills-content/visual-plan-skill.d.ts +1 -1
- package/dist/cli/skills-content/visual-plan-skill.js +27 -0
- package/dist/cli/skills-content/wireframe.d.ts +2 -2
- package/dist/cli/skills-content/wireframe.js +7 -0
- package/dist/client/AgentPanel.js +23 -8
- package/dist/client/api-path.d.ts +6 -0
- package/dist/client/api-path.js +55 -0
- package/dist/client/onboarding/FirstRunOnboarding.d.ts +3 -0
- package/dist/client/onboarding/FirstRunOnboarding.js +93 -0
- package/dist/client/onboarding/first-run-enabled.d.ts +2 -0
- package/dist/client/onboarding/first-run-enabled.js +9 -0
- package/dist/client/onboarding/index.d.ts +2 -1
- package/dist/client/onboarding/index.js +1 -0
- package/dist/client/onboarding/use-onboarding.d.ts +6 -1
- package/dist/client/onboarding/use-onboarding.js +32 -1
- package/dist/client/route-state.js +10 -0
- package/dist/connections/catalog.d.ts +4 -4
- package/dist/connections/catalog.js +3 -3
- package/dist/db/index.d.ts +1 -1
- package/dist/db/index.js +1 -1
- package/dist/file-upload/actions/upload-image.d.ts +1 -1
- package/dist/notifications/routes.d.ts +5 -5
- package/dist/observability/routes.d.ts +3 -3
- package/dist/onboarding/app-profile.d.ts +4 -0
- package/dist/onboarding/app-profile.js +378 -0
- package/dist/onboarding/index.d.ts +2 -1
- package/dist/onboarding/index.js +1 -0
- package/dist/onboarding/plugin.d.ts +2 -0
- package/dist/onboarding/plugin.js +21 -1
- package/dist/onboarding/types.d.ts +19 -0
- package/dist/progress/routes.d.ts +1 -1
- package/dist/provider-api/actions/custom-provider-registration.d.ts +9 -9
- package/dist/provider-api/actions/provider-api.d.ts +12 -12
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/server/auth.js +21 -1
- package/dist/server/better-auth-instance.d.ts +3 -2
- package/dist/server/better-auth-instance.js +9 -7
- package/dist/server/email-markdown.js +3 -8
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/dist/shared/first-run-onboarding.d.ts +3 -0
- package/dist/shared/first-run-onboarding.js +3 -0
- package/dist/triggers/index.d.ts +3 -0
- package/dist/triggers/index.js +5 -0
- package/docs/content/cross-app-sso.mdx +26 -0
- package/package.json +1 -1
|
@@ -0,0 +1,814 @@
|
|
|
1
|
+
import { defineAction } from "@agent-native/core";
|
|
2
|
+
import { writeAppState } from "@agent-native/core/application-state";
|
|
3
|
+
import {
|
|
4
|
+
createDbExec,
|
|
5
|
+
getDatabaseUrl,
|
|
6
|
+
isLocalDatabase,
|
|
7
|
+
isPostgres,
|
|
8
|
+
} from "@agent-native/core/db";
|
|
9
|
+
import { getRequestUserEmail } from "@agent-native/core/server/request-context";
|
|
10
|
+
import { assertAccess } from "@agent-native/core/sharing";
|
|
11
|
+
import { and, eq, inArray, isNull, ne, sql } from "drizzle-orm";
|
|
12
|
+
import { z } from "zod";
|
|
13
|
+
|
|
14
|
+
import { getDb, schema } from "../server/db/index.js";
|
|
15
|
+
import {
|
|
16
|
+
isBlocksPropertyType,
|
|
17
|
+
isComputedPropertyType,
|
|
18
|
+
type DocumentPropertyType,
|
|
19
|
+
} from "../shared/properties.js";
|
|
20
|
+
import { ensureDocumentFilesMembership } from "./_content-files.js";
|
|
21
|
+
import { getContentDatabaseResponse } from "./_database-utils.js";
|
|
22
|
+
import {
|
|
23
|
+
databaseItemsPositionScope,
|
|
24
|
+
documentsPositionScope,
|
|
25
|
+
withPositionLock,
|
|
26
|
+
} from "./_position-utils.js";
|
|
27
|
+
import { nanoid, normalizedValueJson } from "./_property-utils.js";
|
|
28
|
+
import getDocument from "./get-document.js";
|
|
29
|
+
|
|
30
|
+
const upsertSchema = z.object({
|
|
31
|
+
databaseId: z.string().min(1).describe("Target Content database ID"),
|
|
32
|
+
keyPropertyId: z
|
|
33
|
+
.string()
|
|
34
|
+
.min(1)
|
|
35
|
+
.describe("Database property definition ID used as the stable key"),
|
|
36
|
+
keyValue: z.string().min(1).describe("Non-empty stable key value"),
|
|
37
|
+
title: z
|
|
38
|
+
.string()
|
|
39
|
+
.max(500)
|
|
40
|
+
.optional()
|
|
41
|
+
.describe("Row title to create or update"),
|
|
42
|
+
body: z.string().optional().describe("Row body to create or update"),
|
|
43
|
+
propertyValues: z
|
|
44
|
+
.record(z.string(), z.unknown())
|
|
45
|
+
.optional()
|
|
46
|
+
.describe("Property values keyed by property definition ID"),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
type Identity = { itemId: string; documentId: string };
|
|
50
|
+
|
|
51
|
+
async function withStableKeyReadbackLock<T>(
|
|
52
|
+
scope: string,
|
|
53
|
+
run: () => Promise<T>,
|
|
54
|
+
): Promise<T> {
|
|
55
|
+
const runInProcess = () =>
|
|
56
|
+
withPositionLock(`stableKeyReadback:${scope}`, run);
|
|
57
|
+
|
|
58
|
+
// Every dialect needs one lock that spans both the write transaction and
|
|
59
|
+
// the exact post-commit readback; otherwise a later writer can legitimately
|
|
60
|
+
// overtake the first request between those phases and turn a committed
|
|
61
|
+
// mutation into an ambiguous 500 receipt. Real PostgreSQL workers also need
|
|
62
|
+
// the advisory lock because their in-process promise chains are independent.
|
|
63
|
+
if (!isPostgres() || isLocalDatabase()) return runInProcess();
|
|
64
|
+
|
|
65
|
+
// Never hold an advisory lock in the ordinary shared application pool: a
|
|
66
|
+
// small burst could occupy every connection while the lock winner still
|
|
67
|
+
// needs that pool to perform its write and readback. One process-wide gate
|
|
68
|
+
// bounds this action to one disposable lock connection per process while
|
|
69
|
+
// PostgreSQL coordinates the same scope across independent processes.
|
|
70
|
+
return withPositionLock("stableKeyReadback:postgres-connection", async () => {
|
|
71
|
+
const lockDb = await createDbExec({ url: getDatabaseUrl() });
|
|
72
|
+
try {
|
|
73
|
+
if (!lockDb.transaction) {
|
|
74
|
+
throw new Error("PostgreSQL stable-key locking requires transactions.");
|
|
75
|
+
}
|
|
76
|
+
return await lockDb.transaction(async (tx) => {
|
|
77
|
+
await tx.execute({
|
|
78
|
+
sql: "SELECT pg_advisory_xact_lock(hashtextextended(?, 0))",
|
|
79
|
+
args: [scope],
|
|
80
|
+
});
|
|
81
|
+
return runInProcess();
|
|
82
|
+
});
|
|
83
|
+
} finally {
|
|
84
|
+
await lockDb.close?.();
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export default defineAction({
|
|
90
|
+
description:
|
|
91
|
+
"Atomically create or update one Content database row by a database-scoped stable property key. Returns a created, updated, or unchanged receipt with stable item and document IDs.",
|
|
92
|
+
schema: upsertSchema,
|
|
93
|
+
run: async ({
|
|
94
|
+
databaseId,
|
|
95
|
+
keyPropertyId,
|
|
96
|
+
keyValue,
|
|
97
|
+
title,
|
|
98
|
+
body,
|
|
99
|
+
propertyValues,
|
|
100
|
+
}) => {
|
|
101
|
+
const db = getDb();
|
|
102
|
+
const [database] = await db
|
|
103
|
+
.select()
|
|
104
|
+
.from(schema.contentDatabases)
|
|
105
|
+
.where(
|
|
106
|
+
and(
|
|
107
|
+
eq(schema.contentDatabases.id, databaseId),
|
|
108
|
+
isNull(schema.contentDatabases.deletedAt),
|
|
109
|
+
),
|
|
110
|
+
);
|
|
111
|
+
if (!database) throw new Error(`Database "${databaseId}" not found.`);
|
|
112
|
+
if (database.systemRole) {
|
|
113
|
+
throw new Error(
|
|
114
|
+
"Stable-key upserts are supported only for ordinary Content databases, not system databases.",
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const access = await assertAccess(
|
|
119
|
+
"document",
|
|
120
|
+
database.documentId,
|
|
121
|
+
"editor",
|
|
122
|
+
);
|
|
123
|
+
const databaseDocument = access.resource;
|
|
124
|
+
const databaseSpaceId = database.spaceId ?? databaseDocument.spaceId;
|
|
125
|
+
if (!databaseSpaceId)
|
|
126
|
+
throw new Error("Database does not belong to a Content space.");
|
|
127
|
+
if (
|
|
128
|
+
database.spaceId &&
|
|
129
|
+
databaseDocument.spaceId &&
|
|
130
|
+
database.spaceId !== databaseDocument.spaceId
|
|
131
|
+
) {
|
|
132
|
+
throw new Error(
|
|
133
|
+
`Database "${databaseId}" has inconsistent Content space.`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const definitions = await db
|
|
138
|
+
.select()
|
|
139
|
+
.from(schema.documentPropertyDefinitions)
|
|
140
|
+
.where(
|
|
141
|
+
and(
|
|
142
|
+
eq(schema.documentPropertyDefinitions.databaseId, databaseId),
|
|
143
|
+
eq(
|
|
144
|
+
schema.documentPropertyDefinitions.ownerEmail,
|
|
145
|
+
database.ownerEmail,
|
|
146
|
+
),
|
|
147
|
+
),
|
|
148
|
+
);
|
|
149
|
+
const definitionsById = new Map(
|
|
150
|
+
definitions.map((definition) => [definition.id, definition]),
|
|
151
|
+
);
|
|
152
|
+
const keyDefinition = definitionsById.get(keyPropertyId);
|
|
153
|
+
if (!keyDefinition)
|
|
154
|
+
throw new Error(
|
|
155
|
+
`Key property "${keyPropertyId}" does not belong to database "${databaseId}".`,
|
|
156
|
+
);
|
|
157
|
+
const keyType = keyDefinition.type as DocumentPropertyType;
|
|
158
|
+
const [sourceManagedKey] = await db
|
|
159
|
+
.select({ id: schema.contentDatabaseSourceFields.id })
|
|
160
|
+
.from(schema.contentDatabaseSourceFields)
|
|
161
|
+
.innerJoin(
|
|
162
|
+
schema.contentDatabaseSources,
|
|
163
|
+
eq(
|
|
164
|
+
schema.contentDatabaseSources.id,
|
|
165
|
+
schema.contentDatabaseSourceFields.sourceId,
|
|
166
|
+
),
|
|
167
|
+
)
|
|
168
|
+
.where(
|
|
169
|
+
and(
|
|
170
|
+
eq(schema.contentDatabaseSources.databaseId, databaseId),
|
|
171
|
+
eq(schema.contentDatabaseSourceFields.propertyId, keyPropertyId),
|
|
172
|
+
),
|
|
173
|
+
)
|
|
174
|
+
.limit(1);
|
|
175
|
+
if (
|
|
176
|
+
keyDefinition.systemRole ||
|
|
177
|
+
sourceManagedKey ||
|
|
178
|
+
isComputedPropertyType(keyType) ||
|
|
179
|
+
isBlocksPropertyType(keyType)
|
|
180
|
+
) {
|
|
181
|
+
throw new Error(
|
|
182
|
+
`Property "${keyDefinition.name}" cannot be used as a stable key.`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
const keyValueJson = normalizedValueJson(keyType, keyValue);
|
|
186
|
+
if (keyValueJson === "null" || keyValueJson === '\"\"')
|
|
187
|
+
throw new Error("Stable key value must normalize to a non-empty value.");
|
|
188
|
+
|
|
189
|
+
// prettier-ignore
|
|
190
|
+
return withStableKeyReadbackLock(
|
|
191
|
+
JSON.stringify([databaseId, keyPropertyId, keyValueJson]),
|
|
192
|
+
async () => {
|
|
193
|
+
const values = new Map<string, string>();
|
|
194
|
+
for (const [propertyId, value] of Object.entries(propertyValues ?? {})) {
|
|
195
|
+
const definition = definitionsById.get(propertyId);
|
|
196
|
+
if (!definition)
|
|
197
|
+
throw new Error(
|
|
198
|
+
`Property "${propertyId}" does not belong to database "${databaseId}".`,
|
|
199
|
+
);
|
|
200
|
+
const type = definition.type as DocumentPropertyType;
|
|
201
|
+
if (
|
|
202
|
+
definition.systemRole ||
|
|
203
|
+
isComputedPropertyType(type) ||
|
|
204
|
+
isBlocksPropertyType(type)
|
|
205
|
+
) {
|
|
206
|
+
throw new Error(
|
|
207
|
+
`Property "${definition.name}" cannot be written by this action.`,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
const valueJson = normalizedValueJson(type, value);
|
|
211
|
+
if (propertyId === keyPropertyId && valueJson !== keyValueJson) {
|
|
212
|
+
throw new Error(
|
|
213
|
+
"propertyValues must not change keyPropertyId away from keyValue.",
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
values.set(propertyId, valueJson);
|
|
217
|
+
}
|
|
218
|
+
values.set(keyPropertyId, keyValueJson);
|
|
219
|
+
|
|
220
|
+
const now = new Date().toISOString();
|
|
221
|
+
const proposed: Identity = { itemId: nanoid(), documentId: nanoid() };
|
|
222
|
+
const inheritedShares = await db
|
|
223
|
+
.select({
|
|
224
|
+
principalType: schema.documentShares.principalType,
|
|
225
|
+
principalId: schema.documentShares.principalId,
|
|
226
|
+
role: schema.documentShares.role,
|
|
227
|
+
})
|
|
228
|
+
.from(schema.documentShares)
|
|
229
|
+
.where(eq(schema.documentShares.resourceId, database.documentId));
|
|
230
|
+
|
|
231
|
+
const result = await withPositionLock(
|
|
232
|
+
documentsPositionScope(database.ownerEmail, database.documentId),
|
|
233
|
+
() =>
|
|
234
|
+
withPositionLock(databaseItemsPositionScope(databaseId), () =>
|
|
235
|
+
db.transaction(async (tx) => {
|
|
236
|
+
// Serialize against trash/permanent-delete transactions and
|
|
237
|
+
// revalidate the exact database after acquiring the row lock. A
|
|
238
|
+
// pre-transaction access check alone can become stale while an
|
|
239
|
+
// unattended projection is waiting to write.
|
|
240
|
+
const [lockedDatabase] = await tx
|
|
241
|
+
.update(schema.contentDatabases)
|
|
242
|
+
.set({
|
|
243
|
+
updatedAt: sql`${schema.contentDatabases.updatedAt}`,
|
|
244
|
+
})
|
|
245
|
+
.where(
|
|
246
|
+
and(
|
|
247
|
+
eq(schema.contentDatabases.id, databaseId),
|
|
248
|
+
eq(schema.contentDatabases.documentId, database.documentId),
|
|
249
|
+
eq(schema.contentDatabases.ownerEmail, database.ownerEmail),
|
|
250
|
+
isNull(schema.contentDatabases.deletedAt),
|
|
251
|
+
),
|
|
252
|
+
)
|
|
253
|
+
.returning({
|
|
254
|
+
id: schema.contentDatabases.id,
|
|
255
|
+
systemRole: schema.contentDatabases.systemRole,
|
|
256
|
+
});
|
|
257
|
+
if (!lockedDatabase || lockedDatabase.systemRole) {
|
|
258
|
+
throw new Error(
|
|
259
|
+
`Database "${databaseId}" is no longer an active ordinary Content database.`,
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Lock and revalidate every requested definition after the database
|
|
264
|
+
// lock. Property deletion/configuration touches these same rows, so
|
|
265
|
+
// either it commits first and this fails closed, or it waits until
|
|
266
|
+
// the upsert has committed a self-consistent claim/value set.
|
|
267
|
+
const requestedPropertyIds = [...values.keys()];
|
|
268
|
+
const lockedDefinitions = await tx
|
|
269
|
+
.update(schema.documentPropertyDefinitions)
|
|
270
|
+
.set({
|
|
271
|
+
updatedAt: sql`${schema.documentPropertyDefinitions.updatedAt}`,
|
|
272
|
+
})
|
|
273
|
+
.where(
|
|
274
|
+
and(
|
|
275
|
+
eq(schema.documentPropertyDefinitions.databaseId, databaseId),
|
|
276
|
+
eq(
|
|
277
|
+
schema.documentPropertyDefinitions.ownerEmail,
|
|
278
|
+
database.ownerEmail,
|
|
279
|
+
),
|
|
280
|
+
inArray(
|
|
281
|
+
schema.documentPropertyDefinitions.id,
|
|
282
|
+
requestedPropertyIds,
|
|
283
|
+
),
|
|
284
|
+
),
|
|
285
|
+
)
|
|
286
|
+
.returning({
|
|
287
|
+
id: schema.documentPropertyDefinitions.id,
|
|
288
|
+
type: schema.documentPropertyDefinitions.type,
|
|
289
|
+
systemRole: schema.documentPropertyDefinitions.systemRole,
|
|
290
|
+
});
|
|
291
|
+
const lockedDefinitionsById = new Map(
|
|
292
|
+
lockedDefinitions.map((definition) => [
|
|
293
|
+
definition.id,
|
|
294
|
+
definition,
|
|
295
|
+
]),
|
|
296
|
+
);
|
|
297
|
+
for (const propertyId of requestedPropertyIds) {
|
|
298
|
+
const initialDefinition = definitionsById.get(propertyId);
|
|
299
|
+
const lockedDefinition = lockedDefinitionsById.get(propertyId);
|
|
300
|
+
if (
|
|
301
|
+
!initialDefinition ||
|
|
302
|
+
!lockedDefinition ||
|
|
303
|
+
lockedDefinition.type !== initialDefinition.type ||
|
|
304
|
+
lockedDefinition.systemRole !== initialDefinition.systemRole
|
|
305
|
+
) {
|
|
306
|
+
throw new Error(
|
|
307
|
+
`Property "${propertyId}" changed or was deleted before the stable-key upsert could write.`,
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
const lockedType = lockedDefinition.type as DocumentPropertyType;
|
|
311
|
+
if (
|
|
312
|
+
lockedDefinition.systemRole ||
|
|
313
|
+
isComputedPropertyType(lockedType) ||
|
|
314
|
+
isBlocksPropertyType(lockedType)
|
|
315
|
+
) {
|
|
316
|
+
throw new Error(
|
|
317
|
+
`Property "${propertyId}" cannot be written by this action.`,
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const transactionSourceManagedProperties = await tx
|
|
323
|
+
.select({
|
|
324
|
+
propertyId: schema.contentDatabaseSourceFields.propertyId,
|
|
325
|
+
})
|
|
326
|
+
.from(schema.contentDatabaseSourceFields)
|
|
327
|
+
.innerJoin(
|
|
328
|
+
schema.contentDatabaseSources,
|
|
329
|
+
eq(
|
|
330
|
+
schema.contentDatabaseSources.id,
|
|
331
|
+
schema.contentDatabaseSourceFields.sourceId,
|
|
332
|
+
),
|
|
333
|
+
)
|
|
334
|
+
.where(
|
|
335
|
+
and(
|
|
336
|
+
eq(schema.contentDatabaseSources.databaseId, databaseId),
|
|
337
|
+
inArray(
|
|
338
|
+
schema.contentDatabaseSourceFields.propertyId,
|
|
339
|
+
requestedPropertyIds,
|
|
340
|
+
),
|
|
341
|
+
),
|
|
342
|
+
);
|
|
343
|
+
if (transactionSourceManagedProperties.length > 0) {
|
|
344
|
+
const managedPropertyId =
|
|
345
|
+
transactionSourceManagedProperties[0].propertyId;
|
|
346
|
+
const managedDefinition = managedPropertyId
|
|
347
|
+
? definitionsById.get(managedPropertyId)
|
|
348
|
+
: undefined;
|
|
349
|
+
if (managedPropertyId === keyPropertyId) {
|
|
350
|
+
throw new Error(
|
|
351
|
+
`Property "${keyDefinition.name}" cannot be used as a stable key.`,
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
throw new Error(
|
|
355
|
+
`Property "${managedDefinition?.name ?? managedPropertyId}" is source-managed and cannot be written by this action.`,
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const matches = await tx
|
|
360
|
+
.select({
|
|
361
|
+
itemId: schema.contentDatabaseItems.id,
|
|
362
|
+
documentId: schema.contentDatabaseItems.documentId,
|
|
363
|
+
trashedAt: schema.documents.trashedAt,
|
|
364
|
+
})
|
|
365
|
+
.from(schema.documentPropertyValues)
|
|
366
|
+
.innerJoin(
|
|
367
|
+
schema.contentDatabaseItems,
|
|
368
|
+
eq(
|
|
369
|
+
schema.contentDatabaseItems.documentId,
|
|
370
|
+
schema.documentPropertyValues.documentId,
|
|
371
|
+
),
|
|
372
|
+
)
|
|
373
|
+
.innerJoin(
|
|
374
|
+
schema.documents,
|
|
375
|
+
eq(schema.documents.id, schema.contentDatabaseItems.documentId),
|
|
376
|
+
)
|
|
377
|
+
.where(
|
|
378
|
+
and(
|
|
379
|
+
eq(schema.contentDatabaseItems.databaseId, databaseId),
|
|
380
|
+
eq(schema.documentPropertyValues.propertyId, keyPropertyId),
|
|
381
|
+
eq(schema.documentPropertyValues.valueJson, keyValueJson),
|
|
382
|
+
),
|
|
383
|
+
);
|
|
384
|
+
const matchByDocument = new Map(
|
|
385
|
+
matches.map((row) => [row.documentId, row]),
|
|
386
|
+
);
|
|
387
|
+
if (matches.some((row) => row.trashedAt))
|
|
388
|
+
throw new Error(
|
|
389
|
+
"Stable key belongs to a trashed database row; restore or resolve it before upserting.",
|
|
390
|
+
);
|
|
391
|
+
if (
|
|
392
|
+
matches.length !== matchByDocument.size ||
|
|
393
|
+
matchByDocument.size > 1
|
|
394
|
+
) {
|
|
395
|
+
throw new Error(
|
|
396
|
+
"Stable key matches multiple database rows; reconcile the duplicates before upserting.",
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const [claim] = await tx
|
|
401
|
+
.select()
|
|
402
|
+
.from(schema.contentDatabaseItemKeyClaims)
|
|
403
|
+
.where(
|
|
404
|
+
and(
|
|
405
|
+
eq(
|
|
406
|
+
schema.contentDatabaseItemKeyClaims.databaseId,
|
|
407
|
+
databaseId,
|
|
408
|
+
),
|
|
409
|
+
eq(
|
|
410
|
+
schema.contentDatabaseItemKeyClaims.propertyId,
|
|
411
|
+
keyPropertyId,
|
|
412
|
+
),
|
|
413
|
+
eq(
|
|
414
|
+
schema.contentDatabaseItemKeyClaims.keyValueJson,
|
|
415
|
+
keyValueJson,
|
|
416
|
+
),
|
|
417
|
+
),
|
|
418
|
+
);
|
|
419
|
+
let identity: Identity | undefined = claim && {
|
|
420
|
+
itemId: claim.itemId,
|
|
421
|
+
documentId: claim.documentId,
|
|
422
|
+
};
|
|
423
|
+
const matched = [...matchByDocument.values()][0];
|
|
424
|
+
if (
|
|
425
|
+
claim &&
|
|
426
|
+
matched &&
|
|
427
|
+
(claim.itemId !== matched.itemId ||
|
|
428
|
+
claim.documentId !== matched.documentId)
|
|
429
|
+
) {
|
|
430
|
+
throw new Error(
|
|
431
|
+
"Stable key claim conflicts with the stored property value; reconcile before upserting.",
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
if (claim && !matched) {
|
|
435
|
+
throw new Error(
|
|
436
|
+
"Stable key claim no longer matches the stored key property; reconcile before upserting.",
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (!identity && matched) {
|
|
441
|
+
const candidate = {
|
|
442
|
+
itemId: matched.itemId,
|
|
443
|
+
documentId: matched.documentId,
|
|
444
|
+
};
|
|
445
|
+
await tx
|
|
446
|
+
.insert(schema.contentDatabaseItemKeyClaims)
|
|
447
|
+
.values({
|
|
448
|
+
id: nanoid(),
|
|
449
|
+
ownerEmail: database.ownerEmail,
|
|
450
|
+
orgId: database.orgId,
|
|
451
|
+
databaseId,
|
|
452
|
+
propertyId: keyPropertyId,
|
|
453
|
+
keyValueJson,
|
|
454
|
+
...candidate,
|
|
455
|
+
createdAt: now,
|
|
456
|
+
updatedAt: now,
|
|
457
|
+
})
|
|
458
|
+
.onConflictDoNothing();
|
|
459
|
+
const [reloaded] = await tx
|
|
460
|
+
.select()
|
|
461
|
+
.from(schema.contentDatabaseItemKeyClaims)
|
|
462
|
+
.where(
|
|
463
|
+
and(
|
|
464
|
+
eq(
|
|
465
|
+
schema.contentDatabaseItemKeyClaims.databaseId,
|
|
466
|
+
databaseId,
|
|
467
|
+
),
|
|
468
|
+
eq(
|
|
469
|
+
schema.contentDatabaseItemKeyClaims.propertyId,
|
|
470
|
+
keyPropertyId,
|
|
471
|
+
),
|
|
472
|
+
eq(
|
|
473
|
+
schema.contentDatabaseItemKeyClaims.keyValueJson,
|
|
474
|
+
keyValueJson,
|
|
475
|
+
),
|
|
476
|
+
),
|
|
477
|
+
);
|
|
478
|
+
if (
|
|
479
|
+
!reloaded ||
|
|
480
|
+
reloaded.itemId !== candidate.itemId ||
|
|
481
|
+
reloaded.documentId !== candidate.documentId
|
|
482
|
+
) {
|
|
483
|
+
throw new Error(
|
|
484
|
+
"Stable key was claimed by a different row; reconcile before upserting.",
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
identity = candidate;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if (!identity) {
|
|
491
|
+
await tx
|
|
492
|
+
.insert(schema.contentDatabaseItemKeyClaims)
|
|
493
|
+
.values({
|
|
494
|
+
id: nanoid(),
|
|
495
|
+
ownerEmail: database.ownerEmail,
|
|
496
|
+
orgId: database.orgId,
|
|
497
|
+
databaseId,
|
|
498
|
+
propertyId: keyPropertyId,
|
|
499
|
+
keyValueJson,
|
|
500
|
+
...proposed,
|
|
501
|
+
createdAt: now,
|
|
502
|
+
updatedAt: now,
|
|
503
|
+
})
|
|
504
|
+
.onConflictDoNothing();
|
|
505
|
+
const [reloaded] = await tx
|
|
506
|
+
.select()
|
|
507
|
+
.from(schema.contentDatabaseItemKeyClaims)
|
|
508
|
+
.where(
|
|
509
|
+
and(
|
|
510
|
+
eq(
|
|
511
|
+
schema.contentDatabaseItemKeyClaims.databaseId,
|
|
512
|
+
databaseId,
|
|
513
|
+
),
|
|
514
|
+
eq(
|
|
515
|
+
schema.contentDatabaseItemKeyClaims.propertyId,
|
|
516
|
+
keyPropertyId,
|
|
517
|
+
),
|
|
518
|
+
eq(
|
|
519
|
+
schema.contentDatabaseItemKeyClaims.keyValueJson,
|
|
520
|
+
keyValueJson,
|
|
521
|
+
),
|
|
522
|
+
),
|
|
523
|
+
);
|
|
524
|
+
if (!reloaded)
|
|
525
|
+
throw new Error("Stable key claim could not be read back.");
|
|
526
|
+
identity = {
|
|
527
|
+
itemId: reloaded.itemId,
|
|
528
|
+
documentId: reloaded.documentId,
|
|
529
|
+
};
|
|
530
|
+
if (
|
|
531
|
+
identity.itemId === proposed.itemId &&
|
|
532
|
+
identity.documentId === proposed.documentId
|
|
533
|
+
) {
|
|
534
|
+
const [maxDoc] = await tx
|
|
535
|
+
.select({ max: sql<number>`COALESCE(MAX(position), -1)` })
|
|
536
|
+
.from(schema.documents)
|
|
537
|
+
.where(
|
|
538
|
+
and(
|
|
539
|
+
eq(schema.documents.ownerEmail, database.ownerEmail),
|
|
540
|
+
eq(schema.documents.parentId, database.documentId),
|
|
541
|
+
),
|
|
542
|
+
);
|
|
543
|
+
const [maxItem] = await tx
|
|
544
|
+
.select({ max: sql<number>`COALESCE(MAX(position), -1)` })
|
|
545
|
+
.from(schema.contentDatabaseItems)
|
|
546
|
+
.where(
|
|
547
|
+
eq(schema.contentDatabaseItems.databaseId, databaseId),
|
|
548
|
+
);
|
|
549
|
+
await tx.insert(schema.documents).values({
|
|
550
|
+
id: proposed.documentId,
|
|
551
|
+
spaceId: databaseSpaceId,
|
|
552
|
+
ownerEmail: database.ownerEmail,
|
|
553
|
+
orgId: database.orgId,
|
|
554
|
+
parentId: database.documentId,
|
|
555
|
+
title: title?.trim() ?? "",
|
|
556
|
+
content: body ?? "",
|
|
557
|
+
icon: null,
|
|
558
|
+
position: (maxDoc?.max ?? -1) + 1,
|
|
559
|
+
isFavorite: 0,
|
|
560
|
+
hideFromSearch: databaseDocument.hideFromSearch ?? 0,
|
|
561
|
+
visibility: databaseDocument.visibility ?? "private",
|
|
562
|
+
createdAt: now,
|
|
563
|
+
updatedAt: now,
|
|
564
|
+
});
|
|
565
|
+
await tx.insert(schema.contentDatabaseItems).values({
|
|
566
|
+
id: proposed.itemId,
|
|
567
|
+
ownerEmail: database.ownerEmail,
|
|
568
|
+
orgId: database.orgId,
|
|
569
|
+
databaseId,
|
|
570
|
+
documentId: proposed.documentId,
|
|
571
|
+
position: (maxItem?.max ?? -1) + 1,
|
|
572
|
+
createdAt: now,
|
|
573
|
+
updatedAt: now,
|
|
574
|
+
});
|
|
575
|
+
await tx.insert(schema.documentPropertyValues).values(
|
|
576
|
+
[...values.entries()].map(([propertyId, valueJson]) => ({
|
|
577
|
+
id: nanoid(),
|
|
578
|
+
ownerEmail: database.ownerEmail,
|
|
579
|
+
documentId: proposed.documentId,
|
|
580
|
+
propertyId,
|
|
581
|
+
valueJson,
|
|
582
|
+
createdAt: now,
|
|
583
|
+
updatedAt: now,
|
|
584
|
+
})),
|
|
585
|
+
);
|
|
586
|
+
if (inheritedShares.length > 0)
|
|
587
|
+
await tx.insert(schema.documentShares).values(
|
|
588
|
+
inheritedShares.map((share) => ({
|
|
589
|
+
id: nanoid(),
|
|
590
|
+
resourceId: proposed.documentId,
|
|
591
|
+
principalType: share.principalType,
|
|
592
|
+
principalId: share.principalId,
|
|
593
|
+
role: share.role,
|
|
594
|
+
createdBy: getRequestUserEmail() ?? database.ownerEmail,
|
|
595
|
+
createdAt: now,
|
|
596
|
+
})),
|
|
597
|
+
);
|
|
598
|
+
await ensureDocumentFilesMembership(
|
|
599
|
+
tx,
|
|
600
|
+
proposed.documentId,
|
|
601
|
+
now,
|
|
602
|
+
);
|
|
603
|
+
return { status: "created" as const, ...identity };
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// Serialize every stable-key update for this canonical row at the
|
|
608
|
+
// database layer. The in-process position lock cannot protect two
|
|
609
|
+
// serverless/PostgreSQL workers, while this no-op UPDATE takes the
|
|
610
|
+
// membership row lock until the surrounding transaction commits.
|
|
611
|
+
// Re-read property values only after acquiring it so two workers
|
|
612
|
+
// cannot both observe a missing value and insert duplicates.
|
|
613
|
+
await tx
|
|
614
|
+
.update(schema.contentDatabaseItems)
|
|
615
|
+
.set({
|
|
616
|
+
updatedAt: sql`${schema.contentDatabaseItems.updatedAt}`,
|
|
617
|
+
})
|
|
618
|
+
.where(
|
|
619
|
+
and(
|
|
620
|
+
eq(schema.contentDatabaseItems.id, identity.itemId),
|
|
621
|
+
eq(schema.contentDatabaseItems.databaseId, databaseId),
|
|
622
|
+
eq(
|
|
623
|
+
schema.contentDatabaseItems.documentId,
|
|
624
|
+
identity.documentId,
|
|
625
|
+
),
|
|
626
|
+
),
|
|
627
|
+
);
|
|
628
|
+
const [claimedMembership] = await tx
|
|
629
|
+
.select({
|
|
630
|
+
itemId: schema.contentDatabaseItems.id,
|
|
631
|
+
documentId: schema.contentDatabaseItems.documentId,
|
|
632
|
+
})
|
|
633
|
+
.from(schema.contentDatabaseItems)
|
|
634
|
+
.where(
|
|
635
|
+
and(
|
|
636
|
+
eq(schema.contentDatabaseItems.id, identity.itemId),
|
|
637
|
+
eq(schema.contentDatabaseItems.databaseId, databaseId),
|
|
638
|
+
eq(
|
|
639
|
+
schema.contentDatabaseItems.documentId,
|
|
640
|
+
identity.documentId,
|
|
641
|
+
),
|
|
642
|
+
),
|
|
643
|
+
);
|
|
644
|
+
if (!claimedMembership) {
|
|
645
|
+
throw new Error(
|
|
646
|
+
"Stable key claim does not resolve to a row in this database.",
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
await assertAccess("document", identity.documentId, "editor");
|
|
650
|
+
// Serialize full-document writes with SQL-backed editor saves. The
|
|
651
|
+
// Content editor reconciles genuinely newer SQL snapshots into the
|
|
652
|
+
// live Y.Doc; taking this row lock and advancing updatedAt
|
|
653
|
+
// monotonically prevents a stale open editor from later winning.
|
|
654
|
+
const [document] = await tx
|
|
655
|
+
.update(schema.documents)
|
|
656
|
+
.set({ updatedAt: sql`${schema.documents.updatedAt}` })
|
|
657
|
+
.where(
|
|
658
|
+
and(
|
|
659
|
+
eq(schema.documents.id, identity.documentId),
|
|
660
|
+
isNull(schema.documents.trashedAt),
|
|
661
|
+
),
|
|
662
|
+
)
|
|
663
|
+
.returning();
|
|
664
|
+
if (!document || document.trashedAt)
|
|
665
|
+
throw new Error(
|
|
666
|
+
"Stable key claim does not resolve to an active document.",
|
|
667
|
+
);
|
|
668
|
+
const existingValues = await tx
|
|
669
|
+
.select()
|
|
670
|
+
.from(schema.documentPropertyValues)
|
|
671
|
+
.where(
|
|
672
|
+
and(
|
|
673
|
+
eq(
|
|
674
|
+
schema.documentPropertyValues.documentId,
|
|
675
|
+
identity.documentId,
|
|
676
|
+
),
|
|
677
|
+
inArray(schema.documentPropertyValues.propertyId, [
|
|
678
|
+
...values.keys(),
|
|
679
|
+
]),
|
|
680
|
+
),
|
|
681
|
+
);
|
|
682
|
+
const existingByProperty = new Map(
|
|
683
|
+
existingValues.map((value) => [value.propertyId, value]),
|
|
684
|
+
);
|
|
685
|
+
if (existingValues.length !== existingByProperty.size)
|
|
686
|
+
throw new Error(
|
|
687
|
+
"Target row has duplicate property values; reconcile before upserting.",
|
|
688
|
+
);
|
|
689
|
+
const changedValues = [...values.entries()].filter(
|
|
690
|
+
([propertyId, valueJson]) =>
|
|
691
|
+
existingByProperty.get(propertyId)?.valueJson !== valueJson,
|
|
692
|
+
);
|
|
693
|
+
const documentChanged =
|
|
694
|
+
(title !== undefined && document.title !== title.trim()) ||
|
|
695
|
+
(body !== undefined && document.content !== body);
|
|
696
|
+
if (!documentChanged && changedValues.length === 0)
|
|
697
|
+
return { status: "unchanged" as const, ...identity };
|
|
698
|
+
if (documentChanged) {
|
|
699
|
+
const mutationTime = new Date().toISOString();
|
|
700
|
+
const updatedAt =
|
|
701
|
+
mutationTime > document.updatedAt
|
|
702
|
+
? mutationTime
|
|
703
|
+
: new Date(
|
|
704
|
+
new Date(document.updatedAt).getTime() + 1,
|
|
705
|
+
).toISOString();
|
|
706
|
+
await tx
|
|
707
|
+
.update(schema.documents)
|
|
708
|
+
.set({
|
|
709
|
+
...(title !== undefined ? { title: title.trim() } : {}),
|
|
710
|
+
...(body !== undefined ? { content: body } : {}),
|
|
711
|
+
updatedAt,
|
|
712
|
+
})
|
|
713
|
+
.where(eq(schema.documents.id, identity.documentId));
|
|
714
|
+
}
|
|
715
|
+
for (const [propertyId, valueJson] of changedValues) {
|
|
716
|
+
const existing = existingByProperty.get(propertyId);
|
|
717
|
+
if (existing)
|
|
718
|
+
await tx
|
|
719
|
+
.update(schema.documentPropertyValues)
|
|
720
|
+
.set({ valueJson, updatedAt: now })
|
|
721
|
+
.where(eq(schema.documentPropertyValues.id, existing.id));
|
|
722
|
+
else
|
|
723
|
+
await tx.insert(schema.documentPropertyValues).values({
|
|
724
|
+
id: nanoid(),
|
|
725
|
+
ownerEmail: database.ownerEmail,
|
|
726
|
+
documentId: identity.documentId,
|
|
727
|
+
propertyId,
|
|
728
|
+
valueJson,
|
|
729
|
+
createdAt: now,
|
|
730
|
+
updatedAt: now,
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
await tx
|
|
734
|
+
.delete(schema.contentDatabaseItemKeyClaims)
|
|
735
|
+
.where(
|
|
736
|
+
and(
|
|
737
|
+
eq(
|
|
738
|
+
schema.contentDatabaseItemKeyClaims.databaseId,
|
|
739
|
+
databaseId,
|
|
740
|
+
),
|
|
741
|
+
eq(
|
|
742
|
+
schema.contentDatabaseItemKeyClaims.propertyId,
|
|
743
|
+
keyPropertyId,
|
|
744
|
+
),
|
|
745
|
+
eq(
|
|
746
|
+
schema.contentDatabaseItemKeyClaims.documentId,
|
|
747
|
+
identity.documentId,
|
|
748
|
+
),
|
|
749
|
+
ne(
|
|
750
|
+
schema.contentDatabaseItemKeyClaims.keyValueJson,
|
|
751
|
+
keyValueJson,
|
|
752
|
+
),
|
|
753
|
+
),
|
|
754
|
+
);
|
|
755
|
+
return { status: "updated" as const, ...identity };
|
|
756
|
+
}),
|
|
757
|
+
),
|
|
758
|
+
);
|
|
759
|
+
|
|
760
|
+
await writeAppState("refresh-signal", { ts: Date.now() }).catch(() => {});
|
|
761
|
+
const readback = await getContentDatabaseResponse(databaseId, {
|
|
762
|
+
limit: 2,
|
|
763
|
+
offset: 0,
|
|
764
|
+
documentIds: [result.documentId],
|
|
765
|
+
});
|
|
766
|
+
const readbackItem = readback.items[0];
|
|
767
|
+
const readbackPropertiesById = new Map(
|
|
768
|
+
readbackItem?.properties.map((property) => [
|
|
769
|
+
property.definition.id,
|
|
770
|
+
property,
|
|
771
|
+
]) ?? [],
|
|
772
|
+
);
|
|
773
|
+
const readbackValuesMatch = [...values.entries()].every(
|
|
774
|
+
([propertyId, expectedValueJson]) => {
|
|
775
|
+
const definition = definitionsById.get(propertyId);
|
|
776
|
+
const property = readbackPropertiesById.get(propertyId);
|
|
777
|
+
return (
|
|
778
|
+
definition !== undefined &&
|
|
779
|
+
property !== undefined &&
|
|
780
|
+
normalizedValueJson(
|
|
781
|
+
definition.type as DocumentPropertyType,
|
|
782
|
+
property.value,
|
|
783
|
+
) === expectedValueJson
|
|
784
|
+
);
|
|
785
|
+
},
|
|
786
|
+
);
|
|
787
|
+
const verifiedDocument = await getDocument.run({
|
|
788
|
+
id: result.documentId,
|
|
789
|
+
databaseId,
|
|
790
|
+
databaseDocumentId: database.documentId,
|
|
791
|
+
});
|
|
792
|
+
if (
|
|
793
|
+
readback.items.length !== 1 ||
|
|
794
|
+
readbackItem?.id !== result.itemId ||
|
|
795
|
+
verifiedDocument?.id !== result.documentId ||
|
|
796
|
+
!readbackValuesMatch ||
|
|
797
|
+
(title !== undefined && readbackItem?.document.title !== title.trim()) ||
|
|
798
|
+
(title !== undefined && verifiedDocument.title !== title.trim()) ||
|
|
799
|
+
(body !== undefined && verifiedDocument.content !== body)
|
|
800
|
+
)
|
|
801
|
+
throw new Error(
|
|
802
|
+
"Stable key upsert could not verify its exact requested row readback.",
|
|
803
|
+
);
|
|
804
|
+
return {
|
|
805
|
+
...result,
|
|
806
|
+
databaseId,
|
|
807
|
+
keyPropertyId,
|
|
808
|
+
keyValue,
|
|
809
|
+
readback: { items: readback.items, pagination: readback.pagination },
|
|
810
|
+
};
|
|
811
|
+
},
|
|
812
|
+
);
|
|
813
|
+
},
|
|
814
|
+
});
|