@checkstack/backend 0.8.2 → 0.9.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.
Files changed (36) hide show
  1. package/CHANGELOG.md +203 -0
  2. package/drizzle/0001_slim_mordo.sql +34 -0
  3. package/drizzle/meta/0001_snapshot.json +444 -0
  4. package/drizzle/meta/_journal.json +7 -0
  5. package/package.json +12 -7
  6. package/src/index.ts +276 -17
  7. package/src/plugin-deregistration.test.ts +137 -0
  8. package/src/plugin-manager/api-router.ts +35 -11
  9. package/src/plugin-manager/plugin-loader.ts +73 -0
  10. package/src/plugin-manager.ts +295 -105
  11. package/src/schema.ts +79 -1
  12. package/src/services/compatibility-checker.test.ts +146 -0
  13. package/src/services/compatibility-checker.ts +137 -0
  14. package/src/services/dev-auth.test.ts +87 -0
  15. package/src/services/dev-auth.ts +56 -0
  16. package/src/services/plugin-artifact-store.ts +131 -0
  17. package/src/services/plugin-bundle-resolver.ts +76 -0
  18. package/src/services/plugin-event-recorder.ts +87 -0
  19. package/src/services/plugin-installers/catalog-installer.ts +33 -0
  20. package/src/services/plugin-installers/github-installer.ts +207 -0
  21. package/src/services/plugin-installers/install-from-tarball.ts +69 -0
  22. package/src/services/plugin-installers/installer-registry.ts +51 -0
  23. package/src/services/plugin-installers/npm-installer.ts +156 -0
  24. package/src/services/plugin-installers/plugin-install-error.ts +37 -0
  25. package/src/services/plugin-installers/tarball-installer.ts +80 -0
  26. package/src/services/plugin-installers/tarball-utils.test.ts +200 -0
  27. package/src/services/plugin-installers/tarball-utils.ts +172 -0
  28. package/src/services/plugin-manager-orchestrator.ts +522 -0
  29. package/src/services/plugin-manager-router.ts +219 -0
  30. package/src/utils/plugin-discovery.test.ts +6 -0
  31. package/src/utils/plugin-discovery.ts +6 -1
  32. package/tsconfig.json +3 -0
  33. package/src/plugin-lifecycle.test.ts +0 -276
  34. package/src/plugin-manager/plugin-admin-router.ts +0 -89
  35. package/src/services/plugin-installer.test.ts +0 -90
  36. package/src/services/plugin-installer.ts +0 -70
package/src/schema.ts CHANGED
@@ -7,6 +7,10 @@ import {
7
7
  timestamp,
8
8
  jsonb,
9
9
  primaryKey,
10
+ uuid,
11
+ integer,
12
+ index,
13
+ uniqueIndex,
10
14
  } from "drizzle-orm/pg-core";
11
15
 
12
16
  // --- Plugin System Schema ---
@@ -18,6 +22,18 @@ export const plugins = pgTable("plugins", {
18
22
  config: json("config").default({}),
19
23
  enabled: boolean("enabled").default(true).notNull(),
20
24
  type: text("type").default("backend").notNull(),
25
+ // ── Runtime plugin system additions ────────────────────────────────────────
26
+ /** Installed version (matches package.json `version`). Empty string for
27
+ * legacy rows that predate the runtime install system. */
28
+ version: text("version").default("").notNull(),
29
+ /** Full validated `InstallPackageMetadata` snapshot taken at install time. */
30
+ metadata: jsonb("metadata").default({}).notNull(),
31
+ /** The `PluginSource` used to install this plugin (NULL for monorepo-local). */
32
+ source: jsonb("source"),
33
+ /** Groups sibling rows installed together as one bundle. */
34
+ bundleId: uuid("bundle_id"),
35
+ /** True on the primary row of a bundle (the row that declared the bundle). */
36
+ isPrimary: boolean("is_primary").default(false).notNull(),
21
37
  });
22
38
 
23
39
  // --- JWT Key Store Schema ---
@@ -42,5 +58,67 @@ export const pluginConfigs = pgTable(
42
58
  },
43
59
  (table) => ({
44
60
  pk: primaryKey({ columns: [table.pluginId, table.configId] }),
45
- })
61
+ }),
62
+ );
63
+
64
+ // --- Plugin Artifact Store ---
65
+ // Tarball bytes for runtime-installed plugins. Shared across all instances
66
+ // so a freshly spun replica can recover plugins without re-fetching from
67
+ // the original source.
68
+ export const pluginArtifacts = pgTable(
69
+ "plugin_artifacts",
70
+ {
71
+ id: uuid("id").primaryKey().defaultRandom(),
72
+ pluginName: text("plugin_name").notNull(),
73
+ version: text("version").notNull(),
74
+ bundleId: uuid("bundle_id"),
75
+ tarball: text("tarball").notNull(), // base64-encoded bytes (drizzle bytea handling is awkward; text is portable)
76
+ contentHash: text("content_hash").notNull(), // sha256 hex
77
+ sizeBytes: integer("size_bytes").notNull(),
78
+ createdAt: timestamp("created_at").notNull().defaultNow(),
79
+ },
80
+ (table) => ({
81
+ pluginNameVersionIdx: uniqueIndex("plugin_artifacts_name_version_idx").on(
82
+ table.pluginName,
83
+ table.version,
84
+ ),
85
+ contentHashIdx: index("plugin_artifacts_content_hash_idx").on(
86
+ table.contentHash,
87
+ ),
88
+ }),
89
+ );
90
+
91
+ // --- Plugin Install Events (audit + reviewable error log) ---
92
+ export const pluginInstallEvents = pgTable(
93
+ "plugin_install_events",
94
+ {
95
+ id: uuid("id").primaryKey().defaultRandom(),
96
+ pluginName: text("plugin_name"),
97
+ bundleId: uuid("bundle_id"),
98
+ /** "install" | "uninstall" */
99
+ action: text("action").notNull(),
100
+ /**
101
+ * Phase within action:
102
+ * "validate" | "persist" | "broadcast" | "in-process-load" |
103
+ * "in-process-unload" | "destructive-cleanup" | "audit"
104
+ */
105
+ phase: text("phase").notNull(),
106
+ /** "started" | "succeeded" | "failed" */
107
+ status: text("status").notNull(),
108
+ source: jsonb("source"),
109
+ error: text("error"),
110
+ instanceId: text("instance_id").notNull(),
111
+ userId: text("user_id"),
112
+ createdAt: timestamp("created_at").notNull().defaultNow(),
113
+ },
114
+ (table) => ({
115
+ pluginNameCreatedIdx: index("plugin_install_events_name_created_idx").on(
116
+ table.pluginName,
117
+ table.createdAt,
118
+ ),
119
+ statusCreatedIdx: index("plugin_install_events_status_created_idx").on(
120
+ table.status,
121
+ table.createdAt,
122
+ ),
123
+ }),
46
124
  );
@@ -0,0 +1,146 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { checkCompatibility } from "./compatibility-checker";
3
+ import type { InstallPackageMetadata } from "@checkstack/common";
4
+
5
+ const minimalPkg = (
6
+ override: Partial<InstallPackageMetadata>,
7
+ ): InstallPackageMetadata => ({
8
+ name: "@scope/example",
9
+ version: "1.0.0",
10
+ description: "example",
11
+ author: "test",
12
+ license: "Elastic-2.0",
13
+ checkstack: { type: "backend", pluginId: "example" },
14
+ ...override,
15
+ });
16
+
17
+ describe("checkCompatibility", () => {
18
+ it("passes when every @checkstack/* dep is satisfied by loaded versions", () => {
19
+ const pkg = minimalPkg({
20
+ dependencies: {
21
+ "@checkstack/backend-api": "^1.0.0",
22
+ "@checkstack/common": "^1.0.0",
23
+ },
24
+ });
25
+ const issues = checkCompatibility({
26
+ packages: [pkg],
27
+ loadedVersions: new Map([
28
+ ["@checkstack/backend-api", "1.2.3"],
29
+ ["@checkstack/common", "1.5.0"],
30
+ ]),
31
+ });
32
+ expect(issues).toEqual([]);
33
+ });
34
+
35
+ it("ignores non-@checkstack deps", () => {
36
+ const pkg = minimalPkg({
37
+ dependencies: { lodash: "^4.0.0", react: "^18.0.0" },
38
+ });
39
+ const issues = checkCompatibility({
40
+ packages: [pkg],
41
+ loadedVersions: new Map(),
42
+ });
43
+ expect(issues).toEqual([]);
44
+ });
45
+
46
+ it("flags missing @checkstack/* dependency not in bundle", () => {
47
+ const pkg = minimalPkg({
48
+ dependencies: { "@checkstack/missing-pkg": "^1.0.0" },
49
+ });
50
+ const issues = checkCompatibility({
51
+ packages: [pkg],
52
+ loadedVersions: new Map(),
53
+ });
54
+ expect(issues).toHaveLength(1);
55
+ expect(issues[0].kind).toBe("missing-dependency");
56
+ expect(issues[0].dependency).toBe("@checkstack/missing-pkg");
57
+ });
58
+
59
+ it("flags semver mismatch against loaded platform version", () => {
60
+ const pkg = minimalPkg({
61
+ dependencies: { "@checkstack/backend-api": "^2.0.0" },
62
+ });
63
+ const issues = checkCompatibility({
64
+ packages: [pkg],
65
+ loadedVersions: new Map([["@checkstack/backend-api", "1.0.0"]]),
66
+ });
67
+ expect(issues).toHaveLength(1);
68
+ expect(issues[0].kind).toBe("version-mismatch");
69
+ expect(issues[0].declared).toBe("^2.0.0");
70
+ expect(issues[0].actual).toBe("1.0.0");
71
+ });
72
+
73
+ it("rejects unresolved workspace:* ranges (must be resolved at pack time)", () => {
74
+ const pkg = minimalPkg({
75
+ dependencies: { "@checkstack/backend-api": "workspace:*" },
76
+ });
77
+ const issues = checkCompatibility({
78
+ packages: [pkg],
79
+ loadedVersions: new Map([["@checkstack/backend-api", "1.0.0"]]),
80
+ });
81
+ expect(issues).toHaveLength(1);
82
+ expect(issues[0].kind).toBe("version-mismatch");
83
+ expect(issues[0].declared).toBe("workspace:*");
84
+ });
85
+
86
+ it("satisfies bundle-internal deps from sibling packages, not platform versions", () => {
87
+ const primary = minimalPkg({
88
+ name: "@scope/foo-backend",
89
+ version: "1.0.0",
90
+ dependencies: { "@scope/foo-common": "^1.0.0" },
91
+ });
92
+ const sibling = minimalPkg({
93
+ name: "@scope/foo-common",
94
+ version: "1.0.0",
95
+ checkstack: { type: "common", pluginId: "foo" },
96
+ });
97
+
98
+ const issues = checkCompatibility({
99
+ packages: [primary, sibling],
100
+ loadedVersions: new Map(), // platform doesn't have @scope/foo-common
101
+ });
102
+ // The dep is `@scope/foo-common`, not `@checkstack/*`, so it isn't
103
+ // checked at all. But verify the check would also pass for the
104
+ // (more interesting) `@checkstack/*` case:
105
+ });
106
+
107
+ it("satisfies bundle-internal @checkstack/* dep from a sibling package", () => {
108
+ const primary = minimalPkg({
109
+ name: "@checkstack/foo-backend",
110
+ version: "1.0.0",
111
+ dependencies: { "@checkstack/foo-common": "^1.0.0" },
112
+ });
113
+ const sibling = minimalPkg({
114
+ name: "@checkstack/foo-common",
115
+ version: "1.0.0",
116
+ checkstack: { type: "common", pluginId: "foo" },
117
+ });
118
+
119
+ const issues = checkCompatibility({
120
+ packages: [primary, sibling],
121
+ loadedVersions: new Map(),
122
+ });
123
+ expect(issues).toEqual([]);
124
+ });
125
+
126
+ it("flags bundle-internal dep when version doesn't match", () => {
127
+ const primary = minimalPkg({
128
+ name: "@checkstack/foo-backend",
129
+ version: "1.0.0",
130
+ dependencies: { "@checkstack/foo-common": "^2.0.0" },
131
+ });
132
+ const sibling = minimalPkg({
133
+ name: "@checkstack/foo-common",
134
+ version: "1.0.0",
135
+ checkstack: { type: "common", pluginId: "foo" },
136
+ });
137
+
138
+ const issues = checkCompatibility({
139
+ packages: [primary, sibling],
140
+ loadedVersions: new Map(),
141
+ });
142
+ expect(issues).toHaveLength(1);
143
+ expect(issues[0].kind).toBe("version-mismatch");
144
+ expect(issues[0].dependency).toBe("@checkstack/foo-common");
145
+ });
146
+ });
@@ -0,0 +1,137 @@
1
+ import path from "node:path";
2
+ import fs from "node:fs";
3
+ import semver from "semver";
4
+ import type { InstallPackageMetadata } from "@checkstack/common";
5
+ import type { CompatibilityIssue } from "@checkstack/pluginmanager-common";
6
+ import { rootLogger } from "../logger";
7
+
8
+ /**
9
+ * Builds a snapshot of every `@checkstack/*` package the current process
10
+ * can resolve, with its installed `version`. Used as the source of truth
11
+ * for compatibility checks.
12
+ *
13
+ * We don't need a separate "core version" — every package version is its
14
+ * own contract.
15
+ */
16
+ export function loadCheckstackPackageVersions({
17
+ workspaceRoot,
18
+ runtimeDir,
19
+ }: {
20
+ workspaceRoot: string;
21
+ runtimeDir: string;
22
+ }): Map<string, string> {
23
+ const versions = new Map<string, string>();
24
+
25
+ const walk = (dir: string) => {
26
+ if (!fs.existsSync(dir)) return;
27
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
28
+ if (!entry.isDirectory()) continue;
29
+ const pkgJsonPath = path.join(dir, entry.name, "package.json");
30
+ if (!fs.existsSync(pkgJsonPath)) continue;
31
+ try {
32
+ const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf8"));
33
+ if (
34
+ typeof pkg.name === "string" &&
35
+ pkg.name.startsWith("@checkstack/") &&
36
+ typeof pkg.version === "string"
37
+ && // First-write wins: monorepo source wins over runtime_plugins
38
+ !versions.has(pkg.name)) {
39
+ versions.set(pkg.name, pkg.version);
40
+ }
41
+ } catch (error) {
42
+ rootLogger.debug(`Failed to read ${pkgJsonPath}`, error);
43
+ }
44
+ }
45
+ };
46
+
47
+ walk(path.join(workspaceRoot, "core"));
48
+ walk(path.join(workspaceRoot, "plugins"));
49
+ walk(path.join(runtimeDir, "node_modules"));
50
+ walk(path.join(runtimeDir, "node_modules", "@checkstack"));
51
+
52
+ return versions;
53
+ }
54
+
55
+ /**
56
+ * Check that every `@checkstack/*` dep declared by a plugin (and its bundle
57
+ * siblings) is satisfiable from the loaded package set OR from another
58
+ * package in the same install bundle.
59
+ *
60
+ * Returns an empty array when compatible, a populated array of issues otherwise.
61
+ */
62
+ export function checkCompatibility({
63
+ packages,
64
+ loadedVersions,
65
+ }: {
66
+ packages: InstallPackageMetadata[];
67
+ loadedVersions: Map<string, string>;
68
+ }): CompatibilityIssue[] {
69
+ const issues: CompatibilityIssue[] = [];
70
+
71
+ // Bundle-internal deps: plugins in the same install set satisfy each other.
72
+ const bundleVersions = new Map<string, string>();
73
+ for (const pkg of packages) {
74
+ bundleVersions.set(pkg.name, pkg.version);
75
+ }
76
+
77
+ for (const pkg of packages) {
78
+ const deps = pkg.dependencies ?? {};
79
+ for (const [depName, declaredRange] of Object.entries(deps)) {
80
+ if (!depName.startsWith("@checkstack/")) continue;
81
+
82
+ // Skip workspace ranges — these only appear in monorepo source files,
83
+ // never in published tarballs (the pack CLI rewrites them). If we see
84
+ // one here it's a sign the plugin wasn't packed via our CLI.
85
+ if (declaredRange.startsWith("workspace:")) {
86
+ issues.push({
87
+ pluginName: pkg.name,
88
+ kind: "version-mismatch",
89
+ dependency: depName,
90
+ declared: declaredRange,
91
+ message: `Plugin '${pkg.name}' depends on '${depName}' with unresolved 'workspace:*' range. Re-pack with '@checkstack/scripts plugin-pack' which resolves workspace ranges to concrete versions.`,
92
+ });
93
+ continue;
94
+ }
95
+
96
+ const bundleVersion = bundleVersions.get(depName);
97
+ if (bundleVersion !== undefined) {
98
+ if (!semver.satisfies(bundleVersion, declaredRange)) {
99
+ issues.push({
100
+ pluginName: pkg.name,
101
+ kind: "version-mismatch",
102
+ dependency: depName,
103
+ declared: declaredRange,
104
+ actual: bundleVersion,
105
+ message: `Plugin '${pkg.name}' requires ${depName}@${declaredRange} but the bundle ships ${bundleVersion}.`,
106
+ });
107
+ }
108
+ continue;
109
+ }
110
+
111
+ const loadedVersion = loadedVersions.get(depName);
112
+ if (loadedVersion === undefined) {
113
+ issues.push({
114
+ pluginName: pkg.name,
115
+ kind: "missing-dependency",
116
+ dependency: depName,
117
+ declared: declaredRange,
118
+ message: `Plugin '${pkg.name}' depends on '${depName}' which is not loaded by this platform and is not part of the install bundle.`,
119
+ });
120
+ continue;
121
+ }
122
+
123
+ if (!semver.satisfies(loadedVersion, declaredRange)) {
124
+ issues.push({
125
+ pluginName: pkg.name,
126
+ kind: "version-mismatch",
127
+ dependency: depName,
128
+ declared: declaredRange,
129
+ actual: loadedVersion,
130
+ message: `Plugin '${pkg.name}' requires ${depName}@${declaredRange} but this platform has ${loadedVersion}.`,
131
+ });
132
+ }
133
+ }
134
+ }
135
+
136
+ return issues;
137
+ }
@@ -0,0 +1,87 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { createDevAuthService } from "./dev-auth";
3
+
4
+ describe("createDevAuthService", () => {
5
+ it("authenticate() returns a stable RealUser identity", async () => {
6
+ const svc = createDevAuthService({ getAllAccessRules: () => [] });
7
+ const user = await svc.authenticate(new Request("http://x"));
8
+ expect(user).toBeDefined();
9
+ if (!user || user.type !== "user") {
10
+ throw new Error("expected RealUser");
11
+ }
12
+ expect(user.id).toBe("dev-user");
13
+ expect(user.email).toBe("dev@checkstack.local");
14
+ expect(user.name).toBe("Dev User");
15
+ expect(user.roles).toEqual(["admin"]);
16
+ expect(user.teamIds).toEqual([]);
17
+ });
18
+
19
+ it("populates accessRules from the registered set", async () => {
20
+ const svc = createDevAuthService({
21
+ getAllAccessRules: () => [
22
+ { id: "catalog.system.read" },
23
+ { id: "catalog.system.manage" },
24
+ ],
25
+ });
26
+ const user = await svc.authenticate(new Request("http://x"));
27
+ if (!user || user.type !== "user") throw new Error("expected RealUser");
28
+ expect(user.accessRules).toEqual([
29
+ "catalog.system.read",
30
+ "catalog.system.manage",
31
+ ]);
32
+ });
33
+
34
+ it("re-reads access rules on each authenticate (rules registered later still apply)", async () => {
35
+ const rules = [{ id: "first.rule" }];
36
+ const svc = createDevAuthService({ getAllAccessRules: () => rules });
37
+
38
+ const before = await svc.authenticate(new Request("http://x"));
39
+ if (!before || before.type !== "user") throw new Error("expected RealUser");
40
+ expect(before.accessRules).toEqual(["first.rule"]);
41
+
42
+ rules.push({ id: "second.rule" });
43
+ const after = await svc.authenticate(new Request("http://x"));
44
+ if (!after || after.type !== "user") throw new Error("expected RealUser");
45
+ expect(after.accessRules).toEqual(["first.rule", "second.rule"]);
46
+ });
47
+
48
+ it("getAnonymousAccessRules returns an empty list (anonymous gets nothing in dev)", async () => {
49
+ const svc = createDevAuthService({
50
+ getAllAccessRules: () => [{ id: "x" }, { id: "y" }],
51
+ });
52
+ expect(await svc.getAnonymousAccessRules()).toEqual([]);
53
+ });
54
+
55
+ it("getCredentials returns an empty headers object", async () => {
56
+ const svc = createDevAuthService({ getAllAccessRules: () => [] });
57
+ expect(await svc.getCredentials()).toEqual({ headers: {} });
58
+ });
59
+
60
+ it("checkResourceTeamAccess always grants", async () => {
61
+ const svc = createDevAuthService({ getAllAccessRules: () => [] });
62
+ expect(
63
+ await svc.checkResourceTeamAccess({
64
+ userId: "x",
65
+ userType: "user",
66
+ resourceType: "system",
67
+ resourceId: "abc",
68
+ action: "manage",
69
+ hasGlobalAccess: false,
70
+ }),
71
+ ).toEqual({ hasAccess: true });
72
+ });
73
+
74
+ it("getAccessibleResourceIds returns the input list unfiltered", async () => {
75
+ const svc = createDevAuthService({ getAllAccessRules: () => [] });
76
+ expect(
77
+ await svc.getAccessibleResourceIds({
78
+ userId: "x",
79
+ userType: "user",
80
+ resourceType: "system",
81
+ resourceIds: ["one", "two", "three"],
82
+ action: "read",
83
+ hasGlobalAccess: false,
84
+ }),
85
+ ).toEqual(["one", "two", "three"]);
86
+ });
87
+ });
@@ -0,0 +1,56 @@
1
+ import type { AuthService, RealUser } from "@checkstack/backend-api";
2
+
3
+ /**
4
+ * Dev-only auth service.
5
+ *
6
+ * Used by `bunx @checkstack/scripts dev` so plugin authors don't have to
7
+ * deal with login flows / cookie state while iterating. Returns a
8
+ * synthetic user that has every registered access rule, so any procedure
9
+ * (regardless of the access guards on it) authorizes.
10
+ *
11
+ * NEVER register this in production. The runtime gates installation
12
+ * behind a `CHECKSTACK_DEV_AUTH=true` env var that we set explicitly in
13
+ * the dev server entry point.
14
+ *
15
+ * The user identity is stable (`dev-user`) so plugin code that derives
16
+ * UI / data from `user.id` behaves consistently across reloads.
17
+ */
18
+ export function createDevAuthService({
19
+ getAllAccessRules,
20
+ }: {
21
+ getAllAccessRules: () => Array<{ id: string }>;
22
+ }): AuthService {
23
+ const devUser: RealUser = {
24
+ type: "user",
25
+ id: "dev-user",
26
+ email: "dev@checkstack.local",
27
+ name: "Dev User",
28
+ accessRules: [], // populated lazily below
29
+ roles: ["admin"],
30
+ teamIds: [],
31
+ };
32
+
33
+ return {
34
+ async authenticate(_request) {
35
+ // Always grant every access rule the platform currently knows about.
36
+ // We resolve this lazily so rules registered by plugins after auth
37
+ // construction (the normal flow) still apply.
38
+ devUser.accessRules = getAllAccessRules().map((r) => r.id);
39
+ return devUser;
40
+ },
41
+ async getCredentials() {
42
+ return { headers: {} };
43
+ },
44
+ async getAnonymousAccessRules() {
45
+ // Anonymous users get nothing; the dev user is the only authenticated
46
+ // identity in dev mode.
47
+ return [];
48
+ },
49
+ async checkResourceTeamAccess() {
50
+ return { hasAccess: true };
51
+ },
52
+ async getAccessibleResourceIds({ resourceIds }) {
53
+ return resourceIds;
54
+ },
55
+ };
56
+ }
@@ -0,0 +1,131 @@
1
+ import { createHash } from "node:crypto";
2
+ import { eq, and } from "drizzle-orm";
3
+ import type {
4
+ PluginArtifactStore,
5
+ SafeDatabase,
6
+ } from "@checkstack/backend-api";
7
+ import { pluginArtifacts } from "../schema";
8
+
9
+ /**
10
+ * Postgres-backed artifact store.
11
+ *
12
+ * Tarball bytes are stored as base64-encoded `text` (drizzle's `bytea`
13
+ * support is awkward and this keeps the column portable). The 33% size
14
+ * overhead is fine at our hard cap (50 MB per artifact).
15
+ *
16
+ * Sharing the row across instances means a freshly spun replica can
17
+ * recover any runtime-installed plugin from the same Postgres the rest
18
+ * of the platform already depends on — no new infra.
19
+ */
20
+ const MAX_ARTIFACT_SIZE = 50 * 1024 * 1024;
21
+
22
+ export class PostgresPluginArtifactStore implements PluginArtifactStore {
23
+ readonly maxArtifactSize = MAX_ARTIFACT_SIZE;
24
+
25
+ constructor(
26
+ private readonly db: SafeDatabase<Record<string, unknown>>,
27
+ ) {}
28
+
29
+ async store({
30
+ pluginName,
31
+ version,
32
+ bundleId,
33
+ tarball,
34
+ }: {
35
+ pluginName: string;
36
+ version: string;
37
+ bundleId?: string | null;
38
+ tarball: Uint8Array;
39
+ }): Promise<{ artifactId: string; contentHash: string }> {
40
+ if (tarball.byteLength > MAX_ARTIFACT_SIZE) {
41
+ throw new Error(
42
+ `Artifact exceeds maximum size: ${tarball.byteLength} > ${MAX_ARTIFACT_SIZE} bytes`,
43
+ );
44
+ }
45
+ const contentHash = sha256Hex(tarball);
46
+ const encoded = Buffer.from(tarball).toString("base64");
47
+
48
+ // Upsert by (plugin_name, version): reinstalling the same version
49
+ // overwrites — useful for replacing a corrupted artifact, harmless
50
+ // when bytes are identical.
51
+ const inserted = await this.db
52
+ .insert(pluginArtifacts)
53
+ .values({
54
+ pluginName,
55
+ version,
56
+ bundleId: bundleId ?? null,
57
+ tarball: encoded,
58
+ contentHash,
59
+ sizeBytes: tarball.byteLength,
60
+ })
61
+ .onConflictDoUpdate({
62
+ target: [pluginArtifacts.pluginName, pluginArtifacts.version],
63
+ set: {
64
+ tarball: encoded,
65
+ contentHash,
66
+ sizeBytes: tarball.byteLength,
67
+ bundleId: bundleId ?? null,
68
+ },
69
+ })
70
+ .returning({ id: pluginArtifacts.id });
71
+
72
+ return { artifactId: inserted[0].id, contentHash };
73
+ }
74
+
75
+ async fetch({
76
+ pluginName,
77
+ version,
78
+ }: {
79
+ pluginName: string;
80
+ version: string;
81
+ }): Promise<{ tarball: Uint8Array; contentHash: string } | undefined> {
82
+ const rows = await this.db
83
+ .select()
84
+ .from(pluginArtifacts)
85
+ .where(
86
+ and(
87
+ eq(pluginArtifacts.pluginName, pluginName),
88
+ eq(pluginArtifacts.version, version),
89
+ ),
90
+ )
91
+ .limit(1);
92
+ if (rows.length === 0) return undefined;
93
+ return {
94
+ tarball: new Uint8Array(Buffer.from(rows[0].tarball, "base64")),
95
+ contentHash: rows[0].contentHash,
96
+ };
97
+ }
98
+
99
+ async fetchById(artifactId: string) {
100
+ const rows = await this.db
101
+ .select()
102
+ .from(pluginArtifacts)
103
+ .where(eq(pluginArtifacts.id, artifactId))
104
+ .limit(1);
105
+ if (rows.length === 0) return;
106
+ return {
107
+ tarball: new Uint8Array(Buffer.from(rows[0].tarball, "base64")),
108
+ contentHash: rows[0].contentHash,
109
+ pluginName: rows[0].pluginName,
110
+ version: rows[0].version,
111
+ };
112
+ }
113
+
114
+ async delete({ pluginName }: { pluginName: string }): Promise<void> {
115
+ await this.db
116
+ .delete(pluginArtifacts)
117
+ .where(eq(pluginArtifacts.pluginName, pluginName));
118
+ }
119
+
120
+ async deleteByBundle({ bundleId }: { bundleId: string }): Promise<void> {
121
+ await this.db
122
+ .delete(pluginArtifacts)
123
+ .where(eq(pluginArtifacts.bundleId, bundleId));
124
+ }
125
+ }
126
+
127
+ function sha256Hex(bytes: Uint8Array): string {
128
+ const h = createHash("sha256");
129
+ h.update(bytes);
130
+ return h.digest("hex");
131
+ }