feat: rebuild workspaces as the reference plugin

This commit is contained in:
LukeGus committed 2026-09-23 07:04:03 -05:00
1 parent d774df8b41
commit f217d8db89
79 files changed
+32995 -2184

No files matched your search

+1 -1
View File
@@ -23,7 +23,7 @@ import { AsyncLocalStorage } from "node:async_hooks";
interface ActorStore {
userId: string;
/** Where the identity came from, for the audit line. */
source: "request" | "asUser";
source: "request" | "asUser" | "service";
}
const storage = new AsyncLocalStorage<ActorStore>();
+16
View File
@@ -45,6 +45,10 @@ import {
} from "@termix/plugin-sdk/backend";
import type { PluginTableDefinition } from "@termix/plugin-sdk/db";
import * as syncRegistry from "./sync-registry.js";
import {
needsExplicitPersist,
resolveDatabaseDialect,
} from "../database/db/dialect.js";
import { createPluginAuth, createPluginSsh } from "./ctx-ssh-auth.js";
export type { PluginModule };
@@ -330,6 +334,18 @@ export function createPluginContext(
define: (definition) => dbDefine(definition) as never,
client: () => dbClient() as never,
refs: () => dbRefs() as never,
// Gated but not audited: it follows every write, and the write itself
// went through a client() call that already left an audit line.
persist: async () => {
await assertCapability(pluginId, "db:own", manifest.capabilities);
if (!needsExplicitPersist(resolveDatabaseDialect())) return;
const { DatabaseSaveTrigger } =
await import("../utils/database-save-trigger.js");
await DatabaseSaveTrigger.forceSave(`plugin_${pluginId}_write`);
},
get dialect() {
return resolveDatabaseDialect();
},
},
sync: {
+4 -74
View File
@@ -19,8 +19,11 @@ import { sql } from "drizzle-orm";
import { pluginLogger } from "../utils/logger.js";
import type { DatabaseDialect } from "../database/db/dialect.js";
import { tablePrefix, LEGACY_TABLE_OWNERS } from "@termix/plugin-sdk/db";
import { splitStatements } from "@termix/plugin-sdk/ddl";
export { LEGACY_TABLE_OWNERS };
// The splitter is shared with createTestDb in the SDK, so tests apply a
// migration exactly the way this runner does.
export { LEGACY_TABLE_OWNERS, splitStatements };
/** Dialect directory names, matching DatabaseDialect exactly. */
const DIALECTS: readonly DatabaseDialect[] = ["sqlite", "postgres", "mysql"];
@@ -94,79 +97,6 @@ export function readMigrations(
return migrations;
}
/**
* Splits a migration file into statements.
*
* Deliberately simple: statements end at a semicolon that is not inside a
* string literal or a comment. A plugin needing more than that should put the
* logic in its own code, not in DDL.
*/
export function splitStatements(source: string): string[] {
const statements: string[] = [];
let current = "";
let quote: string | null = null;
let lineComment = false;
let blockComment = false;
for (let i = 0; i < source.length; i++) {
const char = source[i];
const next = source[i + 1];
if (lineComment) {
current += char;
if (char === "\n") lineComment = false;
continue;
}
if (blockComment) {
current += char;
if (char === "*" && next === "/") {
current += next;
i++;
blockComment = false;
}
continue;
}
if (quote) {
current += char;
// '' inside a quoted string is an escaped quote, not a terminator.
if (char === quote) {
if (next === quote) {
current += next;
i++;
} else {
quote = null;
}
}
continue;
}
if (char === "-" && next === "-") {
lineComment = true;
current += char;
continue;
}
if (char === "/" && next === "*") {
blockComment = true;
current += char;
continue;
}
if (char === "'" || char === '"' || char === "`") {
quote = char;
current += char;
continue;
}
if (char === ";") {
if (current.trim()) statements.push(current.trim());
current = "";
continue;
}
current += char;
}
if (current.trim()) statements.push(current.trim());
return statements;
}
/**
* Tables a migration may create or alter.
*
+9 -3
View File
@@ -37,6 +37,7 @@
import semver from "semver";
import { pluginLogger } from "../utils/logger.js";
import { runAsActor } from "./actor.js";
import type { PluginManifest, PluginServiceRequire } from "./manifest.js";
export interface ServiceRegistration {
@@ -386,9 +387,14 @@ async function invokeGuarded(
}
try {
const result = await (
implementation as (...callArgs: unknown[]) => unknown
).apply(registration.implementation, args);
// Runs as the user the permission was checked for, so the provider reads
// the same user from ctx.currentActor() and cannot be told another one.
const result = await runAsActor(userId, "service", () =>
(implementation as (...callArgs: unknown[]) => unknown).apply(
registration.implementation,
args,
),
);
await context.audit({
userId,
+6 -92
View File
@@ -1,101 +1,15 @@
/**
* Turns a plugin's table definition into the Drizzle table it is queried
* through.
*
* One table object for every dialect. The sqlite-core definitions already
* encode correctly on each engine at query time, so the repositories use them
* everywhere; only DDL genuinely differs, and that lives in the SDK's ddl
* entry so the CLI and the server share one mapping.
* through. The implementation lives in the SDK so createTestDb builds exactly
* the table the server does; the DDL emitters are there too, because
* termix-plugin writes the same SQL this server applies. Re-exported here so
* core keeps one import site.
*/
import {
sqliteTable,
text as sqliteText,
integer as sqliteInteger,
index as sqliteIndex,
uniqueIndex as sqliteUniqueIndex,
type AnySQLiteColumn,
} from "drizzle-orm/sqlite-core";
import { sql } from "drizzle-orm";
import { prefixedTableName } from "@termix/plugin-sdk/db";
import type { PluginTableDefinition } from "@termix/plugin-sdk/db";
import { columnName } from "@termix/plugin-sdk/ddl";
type ColumnChain = {
notNull: () => unknown;
primaryKey: () => unknown;
unique: () => unknown;
default: (value: unknown) => unknown;
};
/**
* Builds the queryable table object.
*
* Always sqlite-core, on every dialect. drizzle's three table classes share no
* base class, and the query-builder surface a plugin uses is identical across
* them - the same deliberate approximation repositories/database-context.ts
* documents for core.
*/
export function buildTable(
pluginId: string,
definition: PluginTableDefinition,
): unknown {
const physical = prefixedTableName(pluginId, definition.name);
const columns: Record<string, unknown> = {};
for (const [property, column] of Object.entries(definition.columns)) {
const name = columnName(property, column);
let built: unknown;
switch (column.type) {
case "id":
built = sqliteInteger(name).primaryKey({ autoIncrement: true });
break;
case "integer":
case "bigint":
case "refHost":
built = sqliteInteger(name);
break;
case "boolean":
built = sqliteInteger(name, { mode: "boolean" });
break;
default:
built = sqliteText(name);
break;
}
if (column.type !== "id") {
if (column.primaryKey) built = (built as ColumnChain).primaryKey();
if (column.notNull) built = (built as ColumnChain).notNull();
if (column.unique) built = (built as ColumnChain).unique();
if (column.defaultNow) {
built = (built as ColumnChain).default(sql`CURRENT_TIMESTAMP`);
} else if (column.defaultValue !== undefined) {
built = (built as ColumnChain).default(column.defaultValue);
}
}
columns[property] = built;
}
return sqliteTable(physical, columns as never, (table) =>
definition.indexes.map((entry) => {
const make = entry.unique ? sqliteUniqueIndex : sqliteIndex;
const target = table as unknown as Record<string, AnySQLiteColumn>;
return make(entry.name).on(
...(entry.columns.map((c) => target[c]) as [
AnySQLiteColumn,
...AnySQLiteColumn[],
]),
);
}),
);
}
// The DDL emitters live in the SDK, because termix-plugin writes the same SQL
// this server applies. Re-exported here so core keeps one import site.
export { buildTable } from "@termix/plugin-sdk/table-builder";
export {
createTableSql,
adoptTableSql,
dropTableSql,
addColumnSql,
keyedColumns,