clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

feat/141-effect-library
lofi / package / schema / private-table.ts
private-table.ts 6.3K
1/**2 * Encrypt-by-default tables: `privateTable` seals every column unless the3 * author opts a column out with `plain`, inverting the posture from "encrypt4 * what is sensitive" to "expose what the server needs". Reference columns5 * stay plaintext automatically — a foreign key the server cannot read cannot6 * join or gate — and each sealed column derives its label from the table's7 * prefix, so labels stay unique and stable per column.8 *9 * ```ts10 * notes: s.privateTable("notes", {11 * body: s.string(), // sealed as encryptedText("notes.body")12 * score: s.int(), // sealed as encryptedNumber("notes.score")13 * at: s.timestamp(), // sealed as encryptedDate("notes.at")14 * meta: s.json(), // sealed as encryptedJson("notes.meta")15 * workspaceId: s.ref("workspaces"), // plaintext: foreign keys stay joinable16 * title: s.plain(s.string()), // plaintext by author choice: a filter target17 * }),18 * ```19 *20 * @module21 */22import {23 type DefinedTable,24 schema,25 type SqlType,26 type TableDefinition,27 type TypedColumnBuilder,28} from "jazz-tools";29import {30 type EncryptedColumn,31 encryptedColumnLabelOf,32 encryptedDate,33 encryptedJson,34 encryptedNumber,35 encryptedText,36} from "./encrypted.ts";3738declare const plainColumnBrand: unique symbol;3940/** A column excluded from sealing by the author; see {@link plain}. */41export type PlainColumn<T> = T & { readonly [plainColumnBrand]: true };4243const PLAIN_COLUMN = Symbol("lofi.plainColumn");4445/** Any pinned-Jazz column builder accepted as a {@link privateTable} column. */46export type AnyColumnBuilder = TypedColumnBuilder<47 SqlType,48 boolean,49 string | undefined,50 boolean,51 unknown52>;5354/**55 * Marks a column of a {@link privateTable} as deliberately plaintext —56 * because it is a filter, sort key, or permission target the server must57 * evaluate. The marker is the visible record of that decision at the column58 * it affects.59 */60export function plain<T extends object>(column: T): PlainColumn<T> {61 Object.defineProperty(column, PLAIN_COLUMN, { value: true });62 return column as PlainColumn<T>;63}6465type BuilderInternals = {66 _sqlType: unknown;67 _nullable?: boolean;68 _default?: unknown;69 _mergeStrategy?: string;70 _transform?: unknown;71 _references?: string;72 [PLAIN_COLUMN]?: true;73};7475/**76 * The column mapping applied by {@link privateTable}: plain-marked columns,77 * reference columns, and byte columns keep their declared type; every other78 * column becomes an {@link EncryptedColumn} of its view type.79 */80export type PrivateTableColumns<TCols> = {81 [K in keyof TCols]: TCols[K] extends { readonly [plainColumnBrand]: true } ? TCols[K]82 : TCols[K] extends { readonly __jazzReferences: infer R } ? (R extends string ? TCols[K]83 : TCols[K] extends { readonly __jazzSqlType: "BYTEA" } ? TCols[K]84 : TCols[K] extends { readonly __jazzValue: infer V } ? EncryptedColumn<V>85 : never)86 : never;87};8889function sealColumn(labelPrefix: string, name: string, builder: unknown): unknown {90 const internals = builder as BuilderInternals;91 if (internals[PLAIN_COLUMN] === true) return builder;92 if (encryptedColumnLabelOf(builder) !== undefined) return builder;93 if (internals._references !== undefined) return builder;94 const label = `${labelPrefix}.${name}`;95 if (internals._nullable === true) {96 throw new TypeError(97 `private table column "${label}" is optional; encrypted columns cannot be optional until ` +98 "the engine's null handling of transformed columns is pinned — mark it s.plain(...) or " +99 "drop .optional()",100 );101 }102 if (internals._default !== undefined) {103 throw new TypeError(104 `private table column "${label}" declares a default; the engine applies defaults below ` +105 "the seal boundary, which would store the default as plaintext — set the value at " +106 "insert time instead",107 );108 }109 if (internals._mergeStrategy !== undefined && internals._mergeStrategy !== "lww") {110 throw new TypeError(111 `private table column "${label}" declares the "${internals._mergeStrategy}" merge ` +112 "strategy, which cannot operate on ciphertext — sealed columns merge last-write-wins",113 );114 }115 if (internals._transform !== undefined) {116 throw new TypeError(117 `private table column "${label}" carries a transform, which sealing would discard — ` +118 "apply the transform in application code around the sealed value, or mark the column " +119 "s.plain(...)",120 );121 }122 const sql = internals._sqlType;123 if (sql === "BYTEA") {124 console.warn(125 `lofi schema: private table column "${label}" holds bytes and stays plaintext — store ` +126 "byte payloads as base64 in an encrypted json column to seal them",127 );128 return builder;129 }130 if (sql === "TEXT") return encryptedText(label);131 if (sql === "INTEGER" || sql === "REAL") return encryptedNumber(label);132 if (sql === "TIMESTAMP") return encryptedDate(label);133 // BOOLEAN, ENUM, JSON, and ARRAY views all round-trip as JSON values.134 return encryptedJson(label);135}136137/**138 * Declares a table whose columns are sealed by default. `labelPrefix` is the139 * cryptographic identity prefix of every sealed column (`"prefix.column"`) —140 * treat it like the table name and never reuse it across tables. Columns opt141 * out of sealing only by being reference columns, byte columns (reported),142 * or wrapped in {@link plain}; optionals, defaults, non-lww merge143 * strategies, and transforms on sealed columns are configuration errors.144 */145export function privateTable<const TCols extends Record<string, AnyColumnBuilder>>(146 labelPrefix: string,147 columns: TCols,148): DefinedTable<149 // Resolves to the exact mapped columns for every concrete schema; the150 // fallback branch only exists to satisfy the DefinedTable constraint while151 // the mapping is still generic.152 PrivateTableColumns<TCols> extends TableDefinition ? PrivateTableColumns<TCols> : TableDefinition153> {154 if (!labelPrefix.trim()) {155 throw new TypeError("privateTable requires a non-empty label prefix");156 }157 const sealed: Record<string, unknown> = {};158 for (const [name, builder] of Object.entries(columns)) {159 sealed[name] = sealColumn(labelPrefix, name, builder);160 }161 return schema.table(sealed as never) as unknown as DefinedTable<162 PrivateTableColumns<TCols> extends TableDefinition ? PrivateTableColumns<TCols>163 : TableDefinition164 >;165}