Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,54 @@ jobs:
- name: Run Content DB tests
run: pnpm test:content-db

- name: Install isolated PGlite test driver
run: >-
npm install --ignore-scripts --no-save
--prefix "$RUNNER_TEMP/s2573-pglite"
@electric-sql/pglite@0.5.4

- name: Run PGlite lock and migration action semantics tests
run: node scripts/test-content-database-lock-pglite.mjs
env:
S2573_PGLITE_INSTALL_PREFIX: ${{ runner.temp }}/s2573-pglite

content-db-postgres-tests:
name: Content DB PostgreSQL locking
runs-on: ubuntu-latest
timeout-minutes: 20
services:
postgres:
image: postgres:17-alpine
env:
POSTGRES_DB: content_migration_test
POSTGRES_HOST_AUTH_METHOD: trust
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres -d content_migration_test"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1

- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0

- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "22"
cache: "pnpm"

- name: Restore dist + tsBuildInfo cache
uses: ./.github/actions/restore-dist-cache

- run: pnpm install --frozen-lockfile

- name: Run Content PostgreSQL lock integration tests
run: pnpm test:content-db-postgres
env:
CONTENT_MIGRATION_POSTGRES_URL: postgres://postgres@127.0.0.1:5432/content_migration_test

core-integration-tests:
name: Core integration tests
runs-on: ubuntu-latest
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@
"fix:imports": "oxfmt --write .",
"test": "tsx scripts/workspace-run.ts test",
"test:fast": "tsx scripts/workspace-run.ts test -- --exclude \"**/*.db.test.ts\" --exclude \"**/*.integration.spec.ts\" --exclude \"**/*.integration.test.ts\" --exclude \"**/*.e2e.spec.ts\" --exclude \"**/*.e2e.test.ts\" --exclude \"**/e2e/**\" --exclude \"**/*.live.spec.ts\" --exclude \"**/*.live.test.ts\" --exclude \"**/*.perf.spec.ts\" --exclude \"**/*.perf.test.ts\" --exclude \"**/create-e2e.spec.ts\"",
"test:content-db": "pnpm --filter content exec vitest --run actions/bind-content-database-source-field.db.test.ts actions/blocks-seeding.db.test.ts actions/builder-source-review-gates.db.test.ts actions/content-database-lifecycle.db.test.ts actions/database-row-batch-actions.db.test.ts actions/list-content-databases.db.test.ts actions/move-document.db.test.ts actions/resync-content-database-source.db.test.ts actions/slack-correction-identity.db.test.ts actions/stage-builder-source-bulk-update.db.test.ts actions/submit-content-database-form.db.test.ts actions/update-document.db.test.ts --config vitest.config.ts",
"test:content-db": "pnpm --filter content exec vitest --run actions/bind-content-database-source-field.db.test.ts actions/blocks-seeding.db.test.ts actions/builder-source-review-gates.db.test.ts actions/content-database-lifecycle.db.test.ts actions/database-row-batch-actions.db.test.ts actions/list-content-databases.db.test.ts actions/migrate-content-database-rows.db.test.ts actions/move-document.db.test.ts actions/resync-content-database-source.db.test.ts actions/slack-correction-identity.db.test.ts actions/stage-builder-source-bulk-update.db.test.ts actions/submit-content-database-form.db.test.ts actions/update-document.db.test.ts --config vitest.config.ts",
"test:content-db-postgres": "pnpm --filter content exec vitest --run actions/migrate-content-database-rows.postgres.integration.test.ts --config vitest.config.ts",
"test:core-integration": "pnpm --filter @agent-native/core exec vitest --run src/agent/engine/translate-ai-sdk.integration.spec.ts src/agent/run-loop-with-resume.integration.spec.ts src/client/extensions/AgentNativeExtensionFrame.e2e.spec.ts src/client/session-replay-iframe.e2e.spec.ts src/scripts/db/migrate-encrypt-credentials.e2e.spec.ts src/scripts/db/scope-isolation.e2e.spec.ts src/server/csrf-plugin-ordering.integration.spec.ts src/server/embedded.integration.spec.ts --passWithNoTests",
"test:plan-e2e": "pnpm --filter plan exec vitest --run actions/create-visual-recap.e2e.spec.ts --passWithNoTests",
"test:trusted-acceptance": "tsx --test scripts/trusted-acceptance.spec.ts scripts/guard-trusted-acceptance-workflow.spec.ts scripts/trusted-acceptance/*.spec.ts",
Expand Down
181 changes: 181 additions & 0 deletions scripts/test-content-database-lock-pglite.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { existsSync, mkdirSync, rmSync, rmdirSync, symlinkSync } from "node:fs";
import { createRequire } from "node:module";
import { join } from "node:path";
import { pathToFileURL } from "node:url";

const installPrefix = process.env.S2573_PGLITE_INSTALL_PREFIX;
if (!installPrefix) {
throw new Error("S2573_PGLITE_INSTALL_PREFIX is required.");
}

const requireFromFixture = createRequire(join(installPrefix, "package.json"));
const entry = requireFromFixture.resolve("@electric-sql/pglite");
const { PGlite } = await import(pathToFileURL(entry).href);
const client = await PGlite.create("memory://");

try {
await client.exec(`
CREATE TABLE content_databases (
id TEXT PRIMARY KEY,
updated_at TEXT NOT NULL
);
CREATE TABLE content_database_items (
id TEXT PRIMARY KEY,
database_id TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE documents (
id TEXT PRIMARY KEY,
content TEXT NOT NULL
);
INSERT INTO content_databases (id, updated_at)
VALUES ('synthetic_pglite_database', '2026-01-01T00:00:00.000Z');
INSERT INTO content_database_items (id, database_id, updated_at)
VALUES (
'synthetic_pglite_membership',
'synthetic_pglite_database',
'2026-01-01T00:00:00.000Z'
);
INSERT INTO documents (id, content)
VALUES ('synthetic_pglite_document', '# Before');
`);

const databaseLock = await client.query(`
UPDATE content_databases
SET updated_at = updated_at
WHERE id = 'synthetic_pglite_database'
RETURNING id, updated_at
`);
const membershipLock = await client.query(`
UPDATE content_database_items
SET updated_at = updated_at
WHERE id = 'synthetic_pglite_membership'
RETURNING id, updated_at
`);
assert.deepEqual(databaseLock.rows, [
{
id: "synthetic_pglite_database",
updated_at: "2026-01-01T00:00:00.000Z",
},
]);
assert.deepEqual(membershipLock.rows, [
{
id: "synthetic_pglite_membership",
updated_at: "2026-01-01T00:00:00.000Z",
},
]);

await client.query(`
UPDATE content_databases
SET updated_at = '2026-01-02T00:00:00.000Z'
WHERE id = 'synthetic_pglite_database'
`);
const touched = await client.query(`
SELECT updated_at
FROM content_databases
WHERE id = 'synthetic_pglite_database'
`);
assert.equal(touched.rows[0]?.updated_at, "2026-01-02T00:00:00.000Z");

let releaseTransaction = () => {};
const transactionReleased = new Promise((resolve) => {
releaseTransaction = resolve;
});
let lockAcquired = () => {};
const transactionLocked = new Promise((resolve) => {
lockAcquired = resolve;
});
const transaction = client.transaction(async (tx) => {
await tx.query(`
UPDATE content_databases
SET updated_at = updated_at
WHERE id = 'synthetic_pglite_database'
`);
lockAcquired();
await transactionReleased;
});
await transactionLocked;

let editorWriteFinished = false;
const editorWrite = client
.query(`
UPDATE documents
SET content = '# Saved editor body'
WHERE id = 'synthetic_pglite_document'
`)
.then(() => {
editorWriteFinished = true;
});
await new Promise((resolve) => setTimeout(resolve, 100));
assert.equal(editorWriteFinished, false);

releaseTransaction();
await transaction;
await editorWrite;
assert.equal(editorWriteFinished, true);
} finally {
await client.close();
}

const driverPackage = join(
installPrefix,
"node_modules",
"@electric-sql",
"pglite",
);
const driverScopes = [
join(process.cwd(), "node_modules", "@electric-sql"),
join(process.cwd(), "packages", "core", "node_modules", "@electric-sql"),
];
const driverLinks = driverScopes.map((scopeDirectory) => ({
scopeDirectory,
scopeExisted: existsSync(scopeDirectory),
driverLink: join(scopeDirectory, "pglite"),
}));
for (const { driverLink } of driverLinks) {
if (existsSync(driverLink)) {
throw new Error(
`Refusing to replace existing PGlite driver at ${driverLink}`,
);
}
}
try {
for (const { scopeDirectory, driverLink } of driverLinks) {
mkdirSync(scopeDirectory, { recursive: true });
symlinkSync(driverPackage, driverLink, "dir");
}
const actionSuite = spawnSync(
"pnpm",
[
"--filter",
"content",
"exec",
"vitest",
"--run",
"actions/migrate-content-database-rows.db.test.ts",
"--config",
"vitest.config.ts",
],
{
cwd: process.cwd(),
env: {
...process.env,
CONTENT_MIGRATION_TEST_BACKEND: "pglite",
},
stdio: "inherit",
},
);
if (actionSuite.error) throw actionSuite.error;
if (actionSuite.status !== 0) {
throw new Error(
`PGlite migration action suite failed with status ${actionSuite.status}.`,
);
}
} finally {
for (const { scopeDirectory, scopeExisted, driverLink } of driverLinks) {
if (existsSync(driverLink)) rmSync(driverLink);
if (!scopeExisted && existsSync(scopeDirectory)) rmdirSync(scopeDirectory);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,19 @@ Use `create-content-database`, `create-inline-content-database`,
`duplicate-document-property`, and `delete-document-property`; do not edit
property rows or view config via raw SQL when an action can do it.

For a bounded migration that must rewrite every existing row body while adding
new property definitions and values, use `migrate-content-database-rows` rather
than looping the single-row actions. Its `validate` phase is read-only; `apply`
commits the complete plan with an idempotency receipt or writes nothing. Read
the database and every row independently after apply, then call its separate
`verify` phase with the saved post-apply digest. Only a verified receipt may
`finalize` the exact legacy property IDs stored in that receipt. `rollback` is
available before finalize only while the saved post-apply digest still matches,
so it never overwrites a later edit. The bounded migration accepts ordinary
databases without attached Sources; source-backed and system databases retain
their dedicated synchronization actions. If flushing a live row editor changes
its persisted revision, read the rows again and build a fresh plan.

When targeting more than one database row, call `duplicate-database-items` or
`remove-database-items` once with a native JSON array of `itemIds` or
`documentIds`. Do not loop `duplicate-database-item` or `delete-document` for
Expand Down
1 change: 1 addition & 0 deletions templates/content/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ ladder.
| `edit-document` | Find/replace edit — preferred for small changes |
| `update-document` | Full rewrite of title, content, or description |
| `delete-document` | Move a page and its children to Trash |
| `migrate-content-database-rows` | Validate, atomically apply, verify, roll back, or finalize one bounded whole-database row migration |

Every action carries its own schema, and the rest of the app-specific surface
(comments, sharing, databases, Notion, local file sources such as
Expand Down
41 changes: 41 additions & 0 deletions templates/content/actions/_content-database-mutation-lock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { eq, sql } from "drizzle-orm";

import { getDb, schema } from "../server/db/index.js";
import { withPositionLock } from "./_position-utils.js";

export function withContentDatabaseMutationLock<T>(
databaseId: string,
run: () => Promise<T>,
) {
return withPositionLock(`contentDatabaseMutation:${databaseId}`, run);
}

/**
* Acquire the database row's write lock before changing database membership,
* schema, or every member at once. Callers must hold the returned lock for the
* whole transaction and take it before reading the state they will mutate.
*/
export async function lockContentDatabaseMutation(
tx: ReturnType<typeof getDb>,
databaseId: string,
) {
const locked = await tx
.update(schema.contentDatabases)
.set({ updatedAt: sql`${schema.contentDatabases.updatedAt}` })
.where(eq(schema.contentDatabases.id, databaseId))
.returning({ id: schema.contentDatabases.id });
if (locked.length !== 1) throw new Error("Database not found.");
}

export async function touchContentDatabase(
tx: ReturnType<typeof getDb>,
databaseId: string,
now = new Date().toISOString(),
) {
const touched = await tx
.update(schema.contentDatabases)
.set({ updatedAt: now })
.where(eq(schema.contentDatabases.id, databaseId))
.returning({ id: schema.contentDatabases.id });
if (touched.length !== 1) throw new Error("Database not found.");
}
Loading
Loading