@checkstack/script-packages-store-postgres 0.2.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/CHANGELOG.md ADDED
@@ -0,0 +1,50 @@
1
+ # @checkstack/script-packages-store-postgres
2
+
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 270ef29: Add the two built-in blob-store plugins for script packages.
8
+
9
+ - `script-packages-store-postgres` (default, zero extra infra): persists
10
+ content-addressed blobs in Postgres as base64 `text` (following the
11
+ existing repo convention for binary columns), registered as the
12
+ `postgres` backend via `blobStoreExtensionPoint`.
13
+ - `script-packages-store-s3` (preferred when configured): S3-compatible
14
+ store backed by Bun's native `S3Client` (no AWS SDK). Config from env
15
+ (`endpoint`, `bucket`, `region`, `accessKeyId`, `secretAccessKey`,
16
+ `forcePathStyle`); credentials never touch the DB. Registers the `s3`
17
+ backend only when configured, and reports a partial-config error
18
+ instead of silently falling back.
19
+
20
+ ### Patch Changes
21
+
22
+ - b995afb: Harden the script-package blob byte boundary against a raw `ArrayBuffer`.
23
+
24
+ Blob bytes can reach the content-hash and storage codecs as a raw `ArrayBuffer` (e.g. from an S3/HTTP transport's `arrayBuffer()`), and Node/Bun's `crypto.Hash.update()` rejects a bare `ArrayBuffer` ("The 'data' argument must be of type string or an instance of Buffer, TypedArray, or DataView. Received an instance of ArrayBuffer"), which would fail a real-package install with `status=error`. `blobSha256` / `verifyBlobSha256` (script-packages-backend) and `encodeBlob` (script-packages-store-postgres) now normalize `ArrayBuffer` to a `Uint8Array` view at the boundary before hashing/encoding. A view over the same bytes hashes and encodes identically, so existing content hashes and stored blobs are unchanged. Adds regression tests feeding an `ArrayBuffer` through both the hash and the Postgres codec.
25
+
26
+ - Updated dependencies [270ef29]
27
+ - Updated dependencies [270ef29]
28
+ - Updated dependencies [270ef29]
29
+ - Updated dependencies [b995afb]
30
+ - Updated dependencies [270ef29]
31
+ - Updated dependencies [270ef29]
32
+ - Updated dependencies [270ef29]
33
+ - Updated dependencies [270ef29]
34
+ - Updated dependencies [b995afb]
35
+ - Updated dependencies [270ef29]
36
+ - Updated dependencies [b995afb]
37
+ - Updated dependencies [270ef29]
38
+ - Updated dependencies [270ef29]
39
+ - Updated dependencies [270ef29]
40
+ - Updated dependencies [b995afb]
41
+ - Updated dependencies [270ef29]
42
+ - Updated dependencies [b995afb]
43
+ - Updated dependencies [270ef29]
44
+ - Updated dependencies [270ef29]
45
+ - Updated dependencies [270ef29]
46
+ - Updated dependencies [270ef29]
47
+ - Updated dependencies [270ef29]
48
+ - @checkstack/backend-api@0.19.0
49
+ - @checkstack/script-packages-backend@0.2.0
50
+ - @checkstack/script-packages-common@0.2.0
@@ -0,0 +1,6 @@
1
+ CREATE TABLE "script_package_blob_data" (
2
+ "integrity" text PRIMARY KEY NOT NULL,
3
+ "data" text NOT NULL,
4
+ "size_bytes" bigint NOT NULL,
5
+ "created_at" timestamp DEFAULT now() NOT NULL
6
+ );
@@ -0,0 +1,57 @@
1
+ {
2
+ "id": "f78fe1fa-2d5c-410f-859b-5d200702f378",
3
+ "prevId": "00000000-0000-0000-0000-000000000000",
4
+ "version": "7",
5
+ "dialect": "postgresql",
6
+ "tables": {
7
+ "public.script_package_blob_data": {
8
+ "name": "script_package_blob_data",
9
+ "schema": "",
10
+ "columns": {
11
+ "integrity": {
12
+ "name": "integrity",
13
+ "type": "text",
14
+ "primaryKey": true,
15
+ "notNull": true
16
+ },
17
+ "data": {
18
+ "name": "data",
19
+ "type": "text",
20
+ "primaryKey": false,
21
+ "notNull": true
22
+ },
23
+ "size_bytes": {
24
+ "name": "size_bytes",
25
+ "type": "bigint",
26
+ "primaryKey": false,
27
+ "notNull": true
28
+ },
29
+ "created_at": {
30
+ "name": "created_at",
31
+ "type": "timestamp",
32
+ "primaryKey": false,
33
+ "notNull": true,
34
+ "default": "now()"
35
+ }
36
+ },
37
+ "indexes": {},
38
+ "foreignKeys": {},
39
+ "compositePrimaryKeys": {},
40
+ "uniqueConstraints": {},
41
+ "policies": {},
42
+ "checkConstraints": {},
43
+ "isRLSEnabled": false
44
+ }
45
+ },
46
+ "enums": {},
47
+ "schemas": {},
48
+ "sequences": {},
49
+ "roles": {},
50
+ "policies": {},
51
+ "views": {},
52
+ "_meta": {
53
+ "columns": {},
54
+ "schemas": {},
55
+ "tables": {}
56
+ }
57
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "version": "7",
3
+ "dialect": "postgresql",
4
+ "entries": [
5
+ {
6
+ "idx": 0,
7
+ "version": "7",
8
+ "when": 1780148042260,
9
+ "tag": "0000_thankful_doorman",
10
+ "breakpoints": true
11
+ }
12
+ ]
13
+ }
@@ -0,0 +1,7 @@
1
+ import { defineConfig } from "drizzle-kit";
2
+
3
+ export default defineConfig({
4
+ schema: "./src/schema.ts",
5
+ out: "./drizzle",
6
+ dialect: "postgresql",
7
+ });
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@checkstack/script-packages-store-postgres",
3
+ "version": "0.2.0",
4
+ "license": "Elastic-2.0",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "checkstack": {
8
+ "type": "backend"
9
+ },
10
+ "scripts": {
11
+ "typecheck": "tsgo -b",
12
+ "lint": "bun run lint:code",
13
+ "lint:code": "eslint . --max-warnings 0",
14
+ "test": "bun test"
15
+ },
16
+ "dependencies": {
17
+ "@checkstack/backend-api": "0.18.0",
18
+ "@checkstack/common": "0.12.0",
19
+ "@checkstack/script-packages-backend": "0.1.0",
20
+ "@checkstack/script-packages-common": "0.1.0",
21
+ "drizzle-orm": "^0.45.0",
22
+ "zod": "^4.0.0"
23
+ },
24
+ "devDependencies": {
25
+ "@checkstack/scripts": "0.3.4",
26
+ "@checkstack/tsconfig": "0.0.7",
27
+ "drizzle-kit": "^0.31.10",
28
+ "typescript": "^5.7.2"
29
+ }
30
+ }
@@ -0,0 +1,32 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { encodeBlob, decodeBlob } from "./codec";
3
+
4
+ describe("blob codec", () => {
5
+ test("round-trips arbitrary bytes", () => {
6
+ const bytes = new Uint8Array([0, 1, 2, 250, 255, 128, 64]);
7
+ expect([...decodeBlob(encodeBlob(bytes))]).toEqual([...bytes]);
8
+ });
9
+
10
+ test("round-trips an empty buffer", () => {
11
+ expect(decodeBlob(encodeBlob(new Uint8Array())).length).toBe(0);
12
+ });
13
+
14
+ test("round-trips a larger random payload", () => {
15
+ const bytes = new Uint8Array(4096);
16
+ for (let i = 0; i < bytes.length; i++) bytes[i] = (i * 37) % 256;
17
+ const decoded = decodeBlob(encodeBlob(bytes));
18
+ expect(decoded.length).toBe(bytes.length);
19
+ expect([...decoded]).toEqual([...bytes]);
20
+ });
21
+
22
+ // Regression: a store `put` may receive a raw ArrayBuffer from an S3/HTTP
23
+ // transport. `encodeBlob` must normalize it (not wrap the whole buffer) so
24
+ // the round-trip is byte-identical to the equivalent Uint8Array.
25
+ test("encodes a raw ArrayBuffer identically to its Uint8Array view", () => {
26
+ const u8 = new Uint8Array([0, 1, 2, 250, 255, 128, 64]);
27
+ const ab = u8.buffer.slice(0);
28
+ expect(ab).toBeInstanceOf(ArrayBuffer);
29
+ expect(encodeBlob(ab)).toBe(encodeBlob(u8));
30
+ expect([...decodeBlob(encodeBlob(ab))]).toEqual([...u8]);
31
+ });
32
+ });
package/src/codec.ts ADDED
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Base64 codec for blob bytes stored in the `text` column.
3
+ *
4
+ * Kept as a separate, pure module so the round-trip is unit-testable
5
+ * without a database.
6
+ */
7
+
8
+ /** Bytes as they arrive at the encode boundary from any blob source. */
9
+ export type BlobBytes = Uint8Array | ArrayBuffer;
10
+
11
+ /**
12
+ * Base64-encode blob bytes for the `text` column. Accepts a raw `ArrayBuffer`
13
+ * (e.g. from an S3/HTTP transport) as well as a `Uint8Array`: `Buffer.from`
14
+ * wraps an `ArrayBuffer` over the whole buffer, so we normalize to a view
15
+ * first to encode exactly the intended bytes.
16
+ */
17
+ export function encodeBlob(bytes: BlobBytes): string {
18
+ const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
19
+ return Buffer.from(view).toString("base64");
20
+ }
21
+
22
+ export function decodeBlob(data: string): Uint8Array {
23
+ return new Uint8Array(Buffer.from(data, "base64"));
24
+ }
package/src/index.ts ADDED
@@ -0,0 +1,38 @@
1
+ import { createBackendPlugin, coreServices } from "@checkstack/backend-api";
2
+ import { blobStoreExtensionPoint } from "@checkstack/script-packages-backend";
3
+ import { pluginMetadata } from "./plugin-metadata";
4
+ import { createPostgresBlobStore, POSTGRES_BLOB_STORE_ID } from "./store";
5
+ import * as schema from "./schema";
6
+
7
+ /**
8
+ * Default blob-store plugin: persists script-package blobs in Postgres
9
+ * (base64 `text`). Always available; selected when no S3 is configured.
10
+ *
11
+ * The store is built in `init()` (where `database` is injected) and
12
+ * registered with the host's `blobStoreExtensionPoint`. The host runs its
13
+ * `register()` first (dep ordering), so the extension point exists by the
14
+ * time this init runs.
15
+ */
16
+ export default createBackendPlugin({
17
+ metadata: pluginMetadata,
18
+
19
+ register(env) {
20
+ env.registerInit({
21
+ schema,
22
+ deps: {
23
+ logger: coreServices.logger,
24
+ },
25
+ init: async ({ logger, database }) => {
26
+ const store = createPostgresBlobStore(database);
27
+ env
28
+ .getExtensionPoint(blobStoreExtensionPoint)
29
+ .registerBlobStore(store, pluginMetadata);
30
+ logger.debug(
31
+ `📦 Registered "${POSTGRES_BLOB_STORE_ID}" script-package blob store.`,
32
+ );
33
+ },
34
+ });
35
+ },
36
+ });
37
+
38
+ export { createPostgresBlobStore, POSTGRES_BLOB_STORE_ID } from "./store";
@@ -0,0 +1,5 @@
1
+ import { definePluginMetadata } from "@checkstack/common";
2
+
3
+ export const pluginMetadata = definePluginMetadata({
4
+ pluginId: "script-packages-store-postgres",
5
+ });
package/src/schema.ts ADDED
@@ -0,0 +1,20 @@
1
+ import { pgTable, text, bigint, timestamp } from "drizzle-orm/pg-core";
2
+
3
+ /**
4
+ * Postgres blob store schema.
5
+ *
6
+ * One row per content-addressed blob, keyed by integrity. Compressed blob
7
+ * bytes are stored base64-encoded in a `text` column - following the
8
+ * existing repo convention (`core/backend/src/schema.ts` does the same for
9
+ * plugin tarballs) because Drizzle's `bytea` handling is awkward and base64
10
+ * `text` is portable. Blobs are bounded by the size guardrail and the
11
+ * lightweight-pure-JS regime, so the ~33% base64 overhead is acceptable for
12
+ * the zero-extra-infra default backend.
13
+ */
14
+ export const scriptPackageBlobData = pgTable("script_package_blob_data", {
15
+ integrity: text("integrity").primaryKey(),
16
+ /** base64-encoded compressed blob bytes. */
17
+ data: text("data").notNull(),
18
+ sizeBytes: bigint("size_bytes", { mode: "number" }).notNull(),
19
+ createdAt: timestamp("created_at").defaultNow().notNull(),
20
+ });
package/src/store.ts ADDED
@@ -0,0 +1,65 @@
1
+ import { eq } from "drizzle-orm";
2
+ import type { SafeDatabase } from "@checkstack/backend-api";
3
+ import type { BlobStore } from "@checkstack/script-packages-backend";
4
+ import { scriptPackageBlobData } from "./schema";
5
+ import { encodeBlob, decodeBlob } from "./codec";
6
+
7
+ export const POSTGRES_BLOB_STORE_ID = "postgres";
8
+
9
+ type Schema = { scriptPackageBlobData: typeof scriptPackageBlobData };
10
+
11
+ /**
12
+ * Postgres-backed {@link BlobStore} - the default, zero-extra-infra
13
+ * backend. Content-addressed: `put` of an already-present integrity is a
14
+ * no-op (identical bytes by definition), so we use `onConflictDoNothing`.
15
+ */
16
+ export function createPostgresBlobStore(
17
+ db: SafeDatabase<Schema>,
18
+ ): BlobStore {
19
+ return {
20
+ id: POSTGRES_BLOB_STORE_ID,
21
+
22
+ async put({ integrity, bytes }) {
23
+ await db
24
+ .insert(scriptPackageBlobData)
25
+ .values({
26
+ integrity,
27
+ data: encodeBlob(bytes),
28
+ sizeBytes: bytes.byteLength,
29
+ })
30
+ .onConflictDoNothing({ target: scriptPackageBlobData.integrity });
31
+ },
32
+
33
+ async get({ integrity }) {
34
+ const rows = await db
35
+ .select({ data: scriptPackageBlobData.data })
36
+ .from(scriptPackageBlobData)
37
+ .where(eq(scriptPackageBlobData.integrity, integrity))
38
+ .limit(1);
39
+ const row = rows[0];
40
+ return row ? decodeBlob(row.data) : undefined;
41
+ },
42
+
43
+ async has({ integrity }) {
44
+ const rows = await db
45
+ .select({ integrity: scriptPackageBlobData.integrity })
46
+ .from(scriptPackageBlobData)
47
+ .where(eq(scriptPackageBlobData.integrity, integrity))
48
+ .limit(1);
49
+ return rows.length > 0;
50
+ },
51
+
52
+ async delete({ integrity }) {
53
+ await db
54
+ .delete(scriptPackageBlobData)
55
+ .where(eq(scriptPackageBlobData.integrity, integrity));
56
+ },
57
+
58
+ async list() {
59
+ const rows = await db
60
+ .select({ integrity: scriptPackageBlobData.integrity })
61
+ .from(scriptPackageBlobData);
62
+ return rows.map((r) => r.integrity);
63
+ },
64
+ };
65
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "extends": "@checkstack/tsconfig/backend.json",
3
+ "include": [
4
+ "src"
5
+ ],
6
+ "references": [
7
+ {
8
+ "path": "../backend-api"
9
+ },
10
+ {
11
+ "path": "../common"
12
+ },
13
+ {
14
+ "path": "../script-packages-backend"
15
+ },
16
+ {
17
+ "path": "../script-packages-common"
18
+ }
19
+ ]
20
+ }