From 6cabac83d5777348d264de4a507f0b9f87d2502a Mon Sep 17 00:00:00 2001 From: slokh Date: Mon, 3 Aug 2026 20:21:35 -0400 Subject: [PATCH 01/26] Add pluggable friction stores --- .changeset/calm-frogs-store.md | 6 ++ README.md | 32 ++++++ src/Entry.ts | 6 +- src/FrictionLog.test.ts | 51 ++++++++++ src/FrictionLog.ts | 62 ++++++++++++ src/PostgresStore.test.ts | 162 +++++++++++++++++++++++++++++++ src/PostgresStore.ts | 171 +++++++++++++++++++++++++++++++++ src/Store.ts | 40 ++++++++ src/index.ts | 7 ++ test/helpers.ts | 1 + 10 files changed, 537 insertions(+), 1 deletion(-) create mode 100644 .changeset/calm-frogs-store.md create mode 100644 src/FrictionLog.test.ts create mode 100644 src/FrictionLog.ts create mode 100644 src/PostgresStore.test.ts create mode 100644 src/PostgresStore.ts diff --git a/.changeset/calm-frogs-store.md b/.changeset/calm-frogs-store.md new file mode 100644 index 0000000..15d85b1 --- /dev/null +++ b/.changeset/calm-frogs-store.md @@ -0,0 +1,6 @@ +--- +'frog': minor +--- + +Add a public friction-store contract, a storage-independent `FrictionLog` API, and an optional +Postgres adapter while preserving the repository file store as the default. diff --git a/README.md b/README.md index 2ac9c1c..e1328e2 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,38 @@ ships a reproduction. Exits 1 on an entry that fails to parse, so it doubles as frog list ``` +### Embed Frog with another store + +Frog's CLI keeps the repository file store as its default. Applications can use the same entry format +and lifecycle with another store by constructing `FrictionLog` with a store adapter. Omitting `store` +uses `.agents/friction-log/`, preserving the normal behavior. + +```ts +import { FrictionLog, PostgresStore } from 'frog' +import { Pool } from 'pg' + +const pool = new Pool({ connectionString: process.env.DATABASE_URL }) +const store = PostgresStore.adapter({ client: pool, namespace: 'support-agent' }) +const frog = new FrictionLog({ store }) + +const result = await frog.record({ + title: 'Search result omitted its freshness', + body: 'The caller could not tell when the result was collected.', + severity: 'major', + context: { source: 'production-agent', execution: 'opaque-reference' }, +}) +``` + +Run `PostgresStore.migrate({ client, namespace })` from the consumer's migration process before using +the adapter. It creates one `frog_entries` table; `namespace` isolates independent applications sharing +that table. The adapter accepts the small `query` interface implemented by `pg` pools and transaction +clients, so Frog does not install a database driver or own connection credentials. + +Every store implements the exported `FrictionStore` contract. Custom stores can retain entries in a +remote service, SQLite, or another database. An adapter may provide atomic `record` behavior; otherwise +`FrictionLog` supplies the file store's normalized-title deduplication. Consumer-defined `context` is +stored without interpretation and is never needed by Frog's core behavior. + ### Logging Upstream Reports friction to another project instead of your own. A target is an npm package or an `owner/repo`, diff --git a/src/Entry.ts b/src/Entry.ts index b891179..040e1d9 100644 --- a/src/Entry.ts +++ b/src/Entry.ts @@ -16,6 +16,8 @@ export const Severity = z.enum(severities) /** Frontmatter of an entry's write-up. */ export type Frontmatter = { + /** Consumer-defined structured context. Frog stores it but does not interpret it. */ + context?: Readonly> | undefined /** Linked issue as `owner/name#number`. Written by publishing, absent while pending. */ issue?: string | undefined /** Extra issue labels, applied on top of the configured and severity labels. */ @@ -39,6 +41,7 @@ export type Frontmatter = { * annotation stops the hand-written type and the schema drifting. */ export const Frontmatter: z.ZodType = z.object({ + context: z.record(z.string(), z.unknown()).optional(), issue: z .string() .regex(/^[\w.-]+\/[\w.-]+#\d+$/) @@ -121,11 +124,12 @@ export declare namespace parse { * @returns File contents, ready to write. Absent optional fields are omitted, not written empty. */ export function serialize(entry: serialize.Options): string { - const { body, issue, labels, severity, target, title } = entry + const { body, context, issue, labels, severity, target, title } = entry const frontmatter = YAML.stringify( { title, severity, + ...(context && Object.keys(context).length ? { context } : {}), ...(target ? { target } : {}), ...(labels?.length ? { labels } : {}), ...(issue ? { issue } : {}), diff --git a/src/FrictionLog.test.ts b/src/FrictionLog.test.ts new file mode 100644 index 0000000..d63efae --- /dev/null +++ b/src/FrictionLog.test.ts @@ -0,0 +1,51 @@ +import { tmpdir } from '../test/helpers.js' +import { FrictionLog } from './FrictionLog.js' + +const entry = { + body: 'It took an unnecessary workaround.', + severity: 'minor', + title: 'Filters ignored', +} as const + +describe('FrictionLog', () => { + test('behavior: defaults to the existing repository-file store', async () => { + const log = new FrictionLog({ root: await tmpdir() }) + const result = await log.record(entry) + + expect(result).toMatchObject({ created: true, occurrences: 1 }) + expect(log.store.name).toBe('file') + expect(await log.list()).toEqual([result.entry]) + }) + + test('behavior: deduplicates normalized titles without changing the file-store default', async () => { + const log = new FrictionLog({ root: await tmpdir() }) + const first = await log.record(entry) + const repeated = await log.record({ ...entry, title: 'filters: ignored!' }) + + expect(repeated).toEqual({ created: false, entry: first.entry, occurrences: 1 }) + expect(await log.list()).toHaveLength(1) + }) + + test('behavior: delegates atomic recording to an adapter that provides it', async () => { + const record = vi.fn(async () => ({ + created: false, + entry: { ...entry, id: 'existing' }, + occurrences: 4, + })) + const log = new FrictionLog({ + store: { + name: 'custom', + record, + read: vi.fn(), + list: vi.fn(), + get: vi.fn(), + write: vi.fn(), + remove: vi.fn(), + files: vi.fn(), + }, + }) + + await expect(log.record(entry)).resolves.toMatchObject({ created: false, occurrences: 4 }) + expect(record).toHaveBeenCalledWith(entry, {}) + }) +}) diff --git a/src/FrictionLog.ts b/src/FrictionLog.ts new file mode 100644 index 0000000..fbf7c90 --- /dev/null +++ b/src/FrictionLog.ts @@ -0,0 +1,62 @@ +import * as Entry from './Entry.js' +import * as Store from './Store.js' + +export type RecordResult = { + /** Whether this call created a new entry. */ + created: boolean + /** The canonical entry representing the friction. */ + entry: Entry.Entry + /** Number of times this adapter has observed the friction, when tracked. */ + occurrences: number +} + +export type Adapter = Store.Adapter & { + /** Optional atomic deduplication supplied by durable adapters. */ + record?( + entry: Entry.serialize.Options, + options?: { force?: boolean | undefined }, + ): Promise +} + +/** A storage-independent friction log for applications, CLIs, and agents. */ +export class FrictionLog { + readonly store: Adapter + + constructor(options: { root?: string | undefined; store?: Adapter | undefined } = {}) { + this.store = options.store ?? Store.adapter({ root: options.root ?? process.cwd() }) + } + + list(): Promise { + return this.store.read() + } + + get(id: string): Promise { + return this.store.get(id) + } + + async record( + entry: Entry.serialize.Options, + options: { force?: boolean | undefined } = {}, + ): Promise { + if (this.store.record) return this.store.record(entry, options) + + if (!options.force) { + const duplicate = (await this.store.read()).find( + (candidate) => Entry.normalizeTitle(candidate.title) === Entry.normalizeTitle(entry.title), + ) + if (duplicate) return { created: false, entry: duplicate, occurrences: 1 } + } + + const written = await this.store.write(entry) + return { created: true, entry: await this.store.get(written.id), occurrences: 1 } + } + + async update(id: string, entry: Entry.serialize.Options): Promise { + await this.store.write(entry, { id }) + return this.store.get(id) + } + + remove(id: string): Promise { + return this.store.remove(id) + } +} diff --git a/src/PostgresStore.test.ts b/src/PostgresStore.test.ts new file mode 100644 index 0000000..1448186 --- /dev/null +++ b/src/PostgresStore.test.ts @@ -0,0 +1,162 @@ +import * as Entry from './Entry.js' +import { FrictionLog } from './FrictionLog.js' +import * as PostgresStore from './PostgresStore.js' + +type Stored = { contents: string; dedupeKey: string; id: string; occurrences: number } + +class FakeClient implements PostgresStore.Client { + readonly queries: string[] = [] + readonly rows = new Map() + + async query = Record>( + text: string, + values: readonly unknown[] = [], + ): Promise<{ rowCount: number; rows: T[] }> { + this.queries.push(text) + if (text.startsWith('CREATE ')) return { rowCount: 0, rows: [] } + + const namespace = String(values[0]) + const key = (id: string) => `${namespace}\u0000${id}` + if (text.includes('ON CONFLICT(namespace, dedupe_key)')) { + const [, rawId, rawDedupe, rawContents] = values + const id = String(rawId) + const dedupeKey = String(rawDedupe) + const existing = [...this.rows.entries()].find( + ([storedKey, row]) => + storedKey.startsWith(`${namespace}\u0000`) && row.dedupeKey === dedupeKey, + )?.[1] + if (existing) { + existing.occurrences += 1 + return { + rowCount: 1, + rows: [ + { + contents: existing.contents, + created: false, + id: existing.id, + occurrence_count: existing.occurrences, + } as unknown as T, + ], + } + } + const stored = { contents: String(rawContents), dedupeKey, id, occurrences: 1 } + this.rows.set(key(id), stored) + return { + rowCount: 1, + rows: [ + { contents: stored.contents, created: true, id, occurrence_count: 1 } as unknown as T, + ], + } + } + if (text.startsWith('INSERT INTO')) { + const [, rawId, rawDedupe, rawContents] = values + const id = String(rawId) + const previous = this.rows.get(key(id)) + this.rows.set(key(id), { + contents: String(rawContents), + dedupeKey: String(rawDedupe), + id, + occurrences: previous?.occurrences ?? 1, + }) + return { rowCount: 1, rows: [] } + } + if (text.startsWith('SELECT id, contents')) { + const selected = + typeof values[1] === 'string' + ? [this.rows.get(key(values[1]))].filter(Boolean) + : [...this.rows.entries()] + .filter(([storedKey]) => storedKey.startsWith(`${namespace}\u0000`)) + .map(([, row]) => row) + return { + rowCount: selected.length, + rows: selected.map( + (row) => + ({ + contents: row!.contents, + id: row!.id, + occurrence_count: row!.occurrences, + }) as unknown as T, + ), + } + } + if (text.startsWith('SELECT id FROM')) { + const selected = [...this.rows.entries()].filter(([storedKey]) => + storedKey.startsWith(`${namespace}\u0000`), + ) + return { + rowCount: selected.length, + rows: selected.map(([, row]) => ({ id: row.id }) as unknown as T), + } + } + if (text.startsWith('DELETE FROM')) { + const removed = this.rows.delete(key(String(values[1]))) + return { rowCount: removed ? 1 : 0, rows: [] } + } + throw new Error(`Unhandled SQL: ${text}`) + } +} + +const friction = { + body: 'The tool required an unnecessary workaround.', + context: { source: 'production-agent', trace: 'opaque-reference' }, + severity: 'major', + title: 'Tool result omitted its state', +} as const + +describe('PostgresStore', () => { + test('behavior: migration is explicit, namespaced, and idempotent SQL', async () => { + const client = new FakeClient() + await PostgresStore.migrate({ client, namespace: 'unused', schema: 'frog' }) + expect(client.queries).toHaveLength(2) + expect(client.queries[0]).toBe('CREATE SCHEMA IF NOT EXISTS "frog"') + expect(client.queries[1]).toContain('CREATE TABLE IF NOT EXISTS "frog"."frog_entries"') + expect(client.queries[1]).toContain('UNIQUE (namespace, dedupe_key)') + }) + + test('behavior: records, deduplicates, updates, lists, and removes through the public API', async () => { + const client = new FakeClient() + const log = new FrictionLog({ + store: PostgresStore.adapter({ client, namespace: 'consumer-a' }), + }) + + const first = await log.record(friction) + const repeated = await log.record({ ...friction, body: 'A later occurrence.' }) + expect(first).toMatchObject({ created: true, occurrences: 1 }) + expect(repeated).toMatchObject({ created: false, occurrences: 2, entry: first.entry }) + expect(first.entry.id).toMatch(/^\d{14}-tool-result-omitted-[0-9a-f]{8}$/) + expect(await log.list()).toEqual([first.entry]) + + const updated = await log.update(first.entry.id, { ...friction, issue: 'wevm/frog#123' }) + expect(updated.issue).toBe('wevm/frog#123') + await expect(log.remove(first.entry.id)).resolves.toBe(true) + await expect(log.remove(first.entry.id)).resolves.toBe(false) + await expect(log.get(first.entry.id)).rejects.toBeInstanceOf(PostgresStore.NotFoundError) + }) + + test('behavior: namespaces isolate consumers and force preserves intentional duplicates', async () => { + const client = new FakeClient() + const first = new FrictionLog({ store: PostgresStore.adapter({ client, namespace: 'one' }) }) + const second = new FrictionLog({ store: PostgresStore.adapter({ client, namespace: 'two' }) }) + + await first.record(friction) + await first.record(friction, { force: true }) + await second.record(friction) + expect(await first.list()).toHaveLength(2) + expect(await second.list()).toHaveLength(1) + }) + + test('error: rejects unsafe schema names before issuing SQL', () => { + expect(() => + PostgresStore.adapter({ + client: new FakeClient(), + namespace: 'one', + schema: 'public; DROP TABLE users', + }), + ).toThrow('Postgres schema must be a SQL identifier.') + }) + + test('behavior: consumer context round trips without Frog interpreting it', () => { + const serialized = Entry.serialize(friction) + expect(Entry.parse(serialized, { id: 'one' }).context).toEqual(friction.context) + }) +}) diff --git a/src/PostgresStore.ts b/src/PostgresStore.ts new file mode 100644 index 0000000..4649088 --- /dev/null +++ b/src/PostgresStore.ts @@ -0,0 +1,171 @@ +import { randomUUID } from 'node:crypto' +import * as Entry from './Entry.js' +import type * as FrictionLog from './FrictionLog.js' + +/** Minimal structural client implemented by `pg` pools and transaction clients. */ +export type Client = { + query = Record>( + text: string, + values?: readonly unknown[], + ): Promise<{ + /** Number of affected rows when the driver supplies it. */ + rowCount?: number | null | undefined + /** Query result rows. */ + rows: T[] + }> +} + +/** Postgres adapter configuration. */ +export type Options = { + /** Pool or transaction client used for every query. */ + client: Client + /** Isolates independent consumers sharing one table. */ + namespace: string + /** PostgreSQL schema. Defaults to `public`. */ + schema?: string | undefined +} + +type Row = { + contents: string + created?: boolean | undefined + id: string + occurrence_count: number | string +} + +/** Creates the tables required by the Postgres adapter. Safe to call repeatedly. */ +export async function migrate(options: Options): Promise { + const schema = schemaName(options.schema) + const table = tableName(schema) + if (schema !== 'public') await options.client.query(`CREATE SCHEMA IF NOT EXISTS "${schema}"`) + await options.client.query( + `CREATE TABLE IF NOT EXISTS ${table} ( + namespace text NOT NULL, + id text NOT NULL, + dedupe_key text NOT NULL, + contents text NOT NULL, + occurrence_count integer NOT NULL DEFAULT 1 CHECK (occurrence_count > 0), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (namespace, id), + UNIQUE (namespace, dedupe_key) + )`, + ) +} + +/** Postgres-backed Frog store. Schema creation is explicit through {@link migrate}. */ +export function adapter(options: Options): FrictionLog.Adapter { + const namespace = required(options.namespace, 'namespace') + const table = tableName(options.schema) + const client = options.client + + const get = async (id: string): Promise => { + const result = await client.query( + `SELECT id, contents, occurrence_count FROM ${table} WHERE namespace = $1 AND id = $2`, + [namespace, id], + ) + const row = result.rows[0] + if (!row) throw new NotFoundError(id) + return Entry.parse(row.contents, { id: row.id }) + } + + const store: FrictionLog.Adapter = { + name: 'postgres', + async read() { + const result = await client.query( + `SELECT id, contents, occurrence_count FROM ${table} WHERE namespace = $1 ORDER BY id`, + [namespace], + ) + return result.rows.map((row) => Entry.parse(row.contents, { id: row.id })) + }, + async list() { + const result = await client.query<{ id: string }>( + `SELECT id FROM ${table} WHERE namespace = $1 ORDER BY id`, + [namespace], + ) + return result.rows.map((row) => row.id) + }, + get, + async write(entry, writeOptions = {}) { + const id = writeOptions.id ?? newId(entry.title) + const contents = Entry.serialize(entry) + const dedupeKey = `entry:${id}` + await client.query( + `INSERT INTO ${table}(namespace, id, dedupe_key, contents) + VALUES ($1, $2, $3, $4) + ON CONFLICT(namespace, id) DO UPDATE SET + contents = EXCLUDED.contents, + updated_at = now()`, + [namespace, id, dedupeKey, contents], + ) + return { file: location(namespace, id), id } + }, + async remove(id) { + const result = await client.query(`DELETE FROM ${table} WHERE namespace = $1 AND id = $2`, [ + namespace, + id, + ]) + return (result.rowCount ?? 0) > 0 + }, + async files() { + return [] + }, + async record(entry, recordOptions = {}) { + const id = newId(entry.title) + const dedupeKey = recordOptions.force ? `forced:${id}` : Entry.normalizeTitle(entry.title) + const result = await client.query( + `INSERT INTO ${table}(namespace, id, dedupe_key, contents) + VALUES ($1, $2, $3, $4) + ON CONFLICT(namespace, dedupe_key) DO UPDATE SET + occurrence_count = ${table}.occurrence_count + 1, + updated_at = now() + RETURNING id, contents, occurrence_count, (xmax = 0) AS created`, + [namespace, id, dedupeKey, Entry.serialize(entry)], + ) + const row = result.rows[0] + if (!row) throw new Error('Postgres did not return the recorded friction entry.') + return { + created: row.created === true, + entry: Entry.parse(row.contents, { id: row.id }), + occurrences: Number(row.occurrence_count), + } + }, + } + return store +} + +function newId(title: string): string { + return `${Entry.newId({ title })}-${randomUUID().slice(0, 8)}` +} + +function schemaName(schema = 'public'): string { + if (!/^[a-z_][a-z0-9_]*$/i.test(schema)) + throw new Error('Postgres schema must be a SQL identifier.') + return schema +} + +function tableName(schema = 'public'): string { + schema = schemaName(schema) + return `"${schema}"."frog_entries"` +} + +function required(value: string, name: string): string { + const normalized = value.trim() + if (!normalized) throw new Error(`Postgres ${name} is required.`) + return normalized +} + +function location(namespace: string, id: string): string { + return `postgres:${encodeURIComponent(namespace)}/${encodeURIComponent(id)}` +} + +/** Raised when a requested Postgres-backed entry does not exist. */ +export class NotFoundError extends Error { + /** Namespaced class name. */ + override name = 'PostgresStore.NotFoundError' + /** Machine-readable error code. */ + code = 'ENTRY_NOT_FOUND' as const + + constructor(id: string) { + super(`Friction entry \`${id}\` does not exist.`) + } +} diff --git a/src/Store.ts b/src/Store.ts index 82810ea..97d5bf9 100644 --- a/src/Store.ts +++ b/src/Store.ts @@ -17,6 +17,46 @@ export type Options = { root: string } +/** Options for an adapter write. */ +export type AdapterWriteOptions = { + /** Existing entry id to replace. */ + id?: string | undefined +} + +/** Storage operations consumed by Frog's programmatic API. */ +export type Adapter = { + /** Stable adapter name for diagnostics. */ + readonly name: string + /** Lists every entry in stable id order. */ + read(): Promise + /** Lists entry ids in stable order. */ + list(): Promise + /** Reads one entry. */ + get(id: string): Promise + /** Writes an entry, optionally replacing a known id. */ + write( + entry: Entry.serialize.Options, + options?: AdapterWriteOptions, + ): Promise + /** Removes an entry and reports whether it existed. */ + remove(id: string): Promise + /** Lists adapter-owned artifact locations, when the adapter supports artifacts. */ + files(id: string): Promise +} + +/** Binds the existing repository-file store to one root. */ +export function adapter(options: Options): Adapter { + return { + name: 'file', + read: () => read(options), + list: () => list(options), + get: (id) => get(id, options), + write: (entry, writeOptions = {}) => write(entry, { ...writeOptions, root: options.root }), + remove: (id) => remove(id, options), + files: (id) => files(id, options), + } +} + /** * Directory holding an entry and anything needed to reproduce it. * diff --git a/src/index.ts b/src/index.ts index e521222..f21197b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,10 @@ export * as Config from './Config.js' /** The entry format: frontmatter, body, ids, and title normalization. */ export * as Entry from './Entry.js' +/** Storage-independent friction logging for embedded consumers. */ +export { FrictionLog } from './FrictionLog.js' +export type { Adapter as FrictionStore, RecordResult } from './FrictionLog.js' + /** Parses a project's GitHub issue form and renders the entry scaffold it implies. */ export * as IssueForm from './IssueForm.js' @@ -25,6 +29,9 @@ export * as Mirrors from './Mirrors.js' /** Reading and writing entries under `.agents/friction-log`. */ export * as Store from './Store.js' +/** Optional Postgres store accepting any `pg`-compatible client. */ +export * as PostgresStore from './PostgresStore.js' + /** Reconciling local entries against issue state, as a pure plan both adapters can apply. */ export * as Sync from './Sync.js' diff --git a/test/helpers.ts b/test/helpers.ts index 83961e3..3e045c6 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -30,6 +30,7 @@ export async function repo(options: repo.Options = {}): Promise { await git(['init', '--initial-branch=main'], dir) await git(['config', 'user.name', 'Test User'], dir) await git(['config', 'user.email', 'test@example.com'], dir) + await git(['config', 'commit.gpgsign', 'false'], dir) if (options.remote) await git(['remote', 'add', 'origin', options.remote], dir) return dir } From 333d5cdd202329b3c7cad93316a88431f456a8c6 Mon Sep 17 00:00:00 2001 From: slokh Date: Mon, 3 Aug 2026 20:24:57 -0400 Subject: [PATCH 02/26] Expose stored occurrence counts --- README.md | 2 ++ src/FrictionLog.test.ts | 1 + src/FrictionLog.ts | 19 ++++++++++++++++--- src/PostgresStore.test.ts | 1 + src/PostgresStore.ts | 10 ++++++++++ src/index.ts | 2 +- 6 files changed, 31 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index e1328e2..cfdc786 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,8 @@ const result = await frog.record({ severity: 'major', context: { source: 'production-agent', execution: 'opaque-reference' }, }) + +const unresolved = await frog.records() // canonical entries with deduplicated occurrence counts ``` Run `PostgresStore.migrate({ client, namespace })` from the consumer's migration process before using diff --git a/src/FrictionLog.test.ts b/src/FrictionLog.test.ts index d63efae..c3892e6 100644 --- a/src/FrictionLog.test.ts +++ b/src/FrictionLog.test.ts @@ -15,6 +15,7 @@ describe('FrictionLog', () => { expect(result).toMatchObject({ created: true, occurrences: 1 }) expect(log.store.name).toBe('file') expect(await log.list()).toEqual([result.entry]) + expect(await log.records()).toEqual([{ entry: result.entry, occurrences: 1 }]) }) test('behavior: deduplicates normalized titles without changing the file-store default', async () => { diff --git a/src/FrictionLog.ts b/src/FrictionLog.ts index fbf7c90..fdfdb45 100644 --- a/src/FrictionLog.ts +++ b/src/FrictionLog.ts @@ -1,16 +1,23 @@ import * as Entry from './Entry.js' import * as Store from './Store.js' -export type RecordResult = { - /** Whether this call created a new entry. */ - created: boolean +/** One canonical entry and the number of times it has been observed. */ +export type StoredEntry = { /** The canonical entry representing the friction. */ entry: Entry.Entry /** Number of times this adapter has observed the friction, when tracked. */ occurrences: number } +/** Result of recording one friction occurrence. */ +export type RecordResult = StoredEntry & { + /** Whether this call created a new entry. */ + created: boolean +} + export type Adapter = Store.Adapter & { + /** Optional occurrence-aware listing supplied by durable adapters. */ + records?(): Promise /** Optional atomic deduplication supplied by durable adapters. */ record?( entry: Entry.serialize.Options, @@ -30,6 +37,12 @@ export class FrictionLog { return this.store.read() } + /** Lists canonical entries with occurrence counts when the store tracks them. */ + async records(): Promise { + if (this.store.records) return this.store.records() + return (await this.store.read()).map((entry) => ({ entry, occurrences: 1 })) + } + get(id: string): Promise { return this.store.get(id) } diff --git a/src/PostgresStore.test.ts b/src/PostgresStore.test.ts index 1448186..d23be4a 100644 --- a/src/PostgresStore.test.ts +++ b/src/PostgresStore.test.ts @@ -125,6 +125,7 @@ describe('PostgresStore', () => { expect(repeated).toMatchObject({ created: false, occurrences: 2, entry: first.entry }) expect(first.entry.id).toMatch(/^\d{14}-tool-result-omitted-[0-9a-f]{8}$/) expect(await log.list()).toEqual([first.entry]) + expect(await log.records()).toEqual([{ entry: first.entry, occurrences: 2 }]) const updated = await log.update(first.entry.id, { ...friction, issue: 'wevm/frog#123' }) expect(updated.issue).toBe('wevm/frog#123') diff --git a/src/PostgresStore.ts b/src/PostgresStore.ts index 4649088..5cf803b 100644 --- a/src/PostgresStore.ts +++ b/src/PostgresStore.ts @@ -84,6 +84,16 @@ export function adapter(options: Options): FrictionLog.Adapter { ) return result.rows.map((row) => row.id) }, + async records() { + const result = await client.query( + `SELECT id, contents, occurrence_count FROM ${table} WHERE namespace = $1 ORDER BY id`, + [namespace], + ) + return result.rows.map((row) => ({ + entry: Entry.parse(row.contents, { id: row.id }), + occurrences: Number(row.occurrence_count), + })) + }, get, async write(entry, writeOptions = {}) { const id = writeOptions.id ?? newId(entry.title) diff --git a/src/index.ts b/src/index.ts index f21197b..da8e520 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,7 +12,7 @@ export * as Entry from './Entry.js' /** Storage-independent friction logging for embedded consumers. */ export { FrictionLog } from './FrictionLog.js' -export type { Adapter as FrictionStore, RecordResult } from './FrictionLog.js' +export type { Adapter as FrictionStore, RecordResult, StoredEntry } from './FrictionLog.js' /** Parses a project's GitHub issue form and renders the entry scaffold it implies. */ export * as IssueForm from './IssueForm.js' From 553a3a80ed3da2001539b32612f0989c4f1092d5 Mon Sep 17 00:00:00 2001 From: slokh Date: Mon, 3 Aug 2026 20:30:23 -0400 Subject: [PATCH 03/26] Respect PostgreSQL search paths --- README.md | 4 +++- src/PostgresStore.test.ts | 13 +++++++++++++ src/PostgresStore.ts | 13 ++++++------- src/Store.ts | 5 +---- 4 files changed, 23 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index cfdc786..8acc283 100644 --- a/README.md +++ b/README.md @@ -207,7 +207,9 @@ const unresolved = await frog.records() // canonical entries with deduplicated o Run `PostgresStore.migrate({ client, namespace })` from the consumer's migration process before using the adapter. It creates one `frog_entries` table; `namespace` isolates independent applications sharing that table. The adapter accepts the small `query` interface implemented by `pg` pools and transaction -clients, so Frog does not install a database driver or own connection credentials. +clients, so Frog does not install a database driver or own connection credentials. With no `schema`, +queries follow the client's current Postgres search path. Pass an explicit `schema` to create and fully +qualify the table there. Every store implements the exported `FrictionStore` contract. Custom stores can retain entries in a remote service, SQLite, or another database. An adapter may provide atomic `record` behavior; otherwise diff --git a/src/PostgresStore.test.ts b/src/PostgresStore.test.ts index d23be4a..4ed012f 100644 --- a/src/PostgresStore.test.ts +++ b/src/PostgresStore.test.ts @@ -113,6 +113,19 @@ describe('PostgresStore', () => { expect(client.queries[1]).toContain('UNIQUE (namespace, dedupe_key)') }) + test('behavior: an omitted schema follows the client search path', async () => { + const client = new FakeClient() + await PostgresStore.migrate({ client, namespace: 'unused' }) + expect(client.queries).toHaveLength(1) + expect(client.queries[0]).toContain('CREATE TABLE IF NOT EXISTS "frog_entries"') + + const log = new FrictionLog({ + store: PostgresStore.adapter({ client, namespace: 'consumer-a' }), + }) + await log.record(friction) + expect(client.queries.at(-1)).toContain('INSERT INTO "frog_entries"') + }) + test('behavior: records, deduplicates, updates, lists, and removes through the public API', async () => { const client = new FakeClient() const log = new FrictionLog({ diff --git a/src/PostgresStore.ts b/src/PostgresStore.ts index 5cf803b..b6df8ce 100644 --- a/src/PostgresStore.ts +++ b/src/PostgresStore.ts @@ -21,7 +21,7 @@ export type Options = { client: Client /** Isolates independent consumers sharing one table. */ namespace: string - /** PostgreSQL schema. Defaults to `public`. */ + /** Optional PostgreSQL schema. Omit it to use the client's current search path. */ schema?: string | undefined } @@ -34,9 +34,9 @@ type Row = { /** Creates the tables required by the Postgres adapter. Safe to call repeatedly. */ export async function migrate(options: Options): Promise { - const schema = schemaName(options.schema) + const schema = options.schema === undefined ? undefined : schemaName(options.schema) const table = tableName(schema) - if (schema !== 'public') await options.client.query(`CREATE SCHEMA IF NOT EXISTS "${schema}"`) + if (schema !== undefined) await options.client.query(`CREATE SCHEMA IF NOT EXISTS "${schema}"`) await options.client.query( `CREATE TABLE IF NOT EXISTS ${table} ( namespace text NOT NULL, @@ -147,15 +147,14 @@ function newId(title: string): string { return `${Entry.newId({ title })}-${randomUUID().slice(0, 8)}` } -function schemaName(schema = 'public'): string { +function schemaName(schema: string): string { if (!/^[a-z_][a-z0-9_]*$/i.test(schema)) throw new Error('Postgres schema must be a SQL identifier.') return schema } -function tableName(schema = 'public'): string { - schema = schemaName(schema) - return `"${schema}"."frog_entries"` +function tableName(schema?: string): string { + return schema === undefined ? '"frog_entries"' : `"${schemaName(schema)}"."frog_entries"` } function required(value: string, name: string): string { diff --git a/src/Store.ts b/src/Store.ts index 97d5bf9..60d8caf 100644 --- a/src/Store.ts +++ b/src/Store.ts @@ -34,10 +34,7 @@ export type Adapter = { /** Reads one entry. */ get(id: string): Promise /** Writes an entry, optionally replacing a known id. */ - write( - entry: Entry.serialize.Options, - options?: AdapterWriteOptions, - ): Promise + write(entry: Entry.serialize.Options, options?: AdapterWriteOptions): Promise /** Removes an entry and reports whether it existed. */ remove(id: string): Promise /** Lists adapter-owned artifact locations, when the adapter supports artifacts. */ From ed18ff8ba48ca4cb09def8e8d7c214842c5d797d Mon Sep 17 00:00:00 2001 From: slokh Date: Mon, 3 Aug 2026 20:43:38 -0400 Subject: [PATCH 04/26] Configure CLI friction stores --- README.md | 21 +++++++++++++++ src/Store.test.ts | 33 ++++++++++++++++++++++++ src/Store.ts | 38 ++++++++++++++++++++++----- src/cli/Cli.ts | 34 +++++++++++++++++++----- src/cli/commands/list.ts | 6 +++++ src/cli/commands/log.ts | 24 +++++++++++++---- src/cli/commands/publish.ts | 9 ++++++- src/cli/commands/resolve.test.ts | 21 +++++++++++++++ src/cli/commands/resolve.ts | 15 +++++++++++ src/cli/commands/sync.ts | 17 ++++++++---- src/cli/internal/store.test.ts | 16 ++++++++++++ src/cli/internal/store.ts | 44 ++++++++++++++++++++++++++++++++ 12 files changed, 255 insertions(+), 23 deletions(-) create mode 100644 src/cli/commands/resolve.test.ts create mode 100644 src/cli/commands/resolve.ts create mode 100644 src/cli/internal/store.test.ts create mode 100644 src/cli/internal/store.ts diff --git a/README.md b/README.md index 8acc283..7d2e25f 100644 --- a/README.md +++ b/README.md @@ -216,6 +216,27 @@ remote service, SQLite, or another database. An adapter may provide atomic `reco `FrictionLog` supplies the file store's normalized-title deduplication. Consumer-defined `context` is stored without interpretation and is never needed by Frog's core behavior. +The CLI can use the same adapter. The repository file store remains the default: + +```sh +frog list +``` + +Select Postgres through environment variables when the application already owns the table migration: + +```sh +FROG_STORE=postgres \ +FROG_NAMESPACE=my-application \ +DATABASE_URL=postgres://... \ +frog list +``` + +`FROG_DATABASE_URL` overrides the conventional `DATABASE_URL`, and `FROG_SCHEMA` optionally qualifies +the table. Install `pg` beside Frog only when selecting Postgres. `frog log`, `frog list`, and `frog +resolve ` then operate on that namespace; no variables or driver are required for the default file +store. Repository-only features such as `list --since`, editing an entry with `log --open`, and artifact +directories remain available only for the file store. + ### Logging Upstream Reports friction to another project instead of your own. A target is an npm package or an `owner/repo`, diff --git a/src/Store.test.ts b/src/Store.test.ts index acb623c..9c63688 100644 --- a/src/Store.test.ts +++ b/src/Store.test.ts @@ -1,6 +1,7 @@ import fs from 'node:fs/promises' import path from 'node:path' import { tmpdir, writeFile } from '../test/helpers.js' +import type * as Entry from './Entry.js' import * as Store from './Store.js' const entry = "---\ntitle: 'Filters are ignored'\n---\n\nBody.\n" @@ -10,6 +11,38 @@ function write(id: string, root: string, contents = entry) { return writeFile(Store.toPath(id), contents, root) } +test('behavior: an async adapter scope redirects every store operation', async () => { + const entries = new Map() + const scoped: Store.Adapter = { + name: 'memory', + read: async () => [...entries.values()], + list: async () => [...entries.keys()], + get: async (id) => entries.get(id)!, + write: async (value, options = {}) => { + const id = options.id ?? 'memory-id' + entries.set(id, { ...value, id }) + return { file: `memory:${id}`, id } + }, + remove: async (id) => entries.delete(id), + files: async () => [], + } + const root = await tmpdir() + const value = { body: 'Body.', severity: 'minor', title: 'Scoped' } as const + + await Store.withAdapter(scoped, async () => { + expect(Store.activeName()).toBe('memory') + await expect(Store.write(value, { root })).resolves.toEqual({ + file: 'memory:memory-id', + id: 'memory-id', + }) + await expect(Store.list({ root })).resolves.toEqual(['memory-id']) + await expect(Store.get('memory-id', { root })).resolves.toEqual({ ...value, id: 'memory-id' }) + await expect(Store.files('memory-id', { root })).resolves.toEqual([]) + await expect(Store.remove('memory-id', { root })).resolves.toBe(true) + }) + expect(Store.activeName()).toBe('file') +}) + describe('write', () => { test('behavior: mints an id and returns the repo-relative path', async () => { const root = await tmpdir() diff --git a/src/Store.ts b/src/Store.ts index 60d8caf..ce33ff7 100644 --- a/src/Store.ts +++ b/src/Store.ts @@ -1,7 +1,10 @@ import fs from 'node:fs/promises' +import { AsyncLocalStorage } from 'node:async_hooks' import path from 'node:path' import * as Entry from './Entry.js' +const activeAdapter = new AsyncLocalStorage() + /** Directory holding entries, relative to the repository root. */ export const dir = '.agents/friction-log' @@ -41,16 +44,27 @@ export type Adapter = { files(id: string): Promise } +/** Runs store operations in one async scope through the supplied adapter. */ +export function withAdapter(store: Adapter, operation: () => Promise): Promise { + return activeAdapter.run(store, operation) +} + +/** Name of the adapter selected for this async scope. Defaults to the repository file store. */ +export function activeName(): string { + return activeAdapter.getStore()?.name ?? 'file' +} + /** Binds the existing repository-file store to one root. */ export function adapter(options: Options): Adapter { return { name: 'file', - read: () => read(options), - list: () => list(options), - get: (id) => get(id, options), - write: (entry, writeOptions = {}) => write(entry, { ...writeOptions, root: options.root }), - remove: (id) => remove(id, options), - files: (id) => files(id, options), + read: () => activeAdapter.run(undefined, () => read(options)), + list: () => activeAdapter.run(undefined, () => list(options)), + get: (id) => activeAdapter.run(undefined, () => get(id, options)), + write: (entry, writeOptions = {}) => + activeAdapter.run(undefined, () => write(entry, { ...writeOptions, root: options.root })), + remove: (id) => activeAdapter.run(undefined, () => remove(id, options)), + files: (id) => activeAdapter.run(undefined, () => files(id, options)), } } @@ -108,6 +122,8 @@ export function toId(file: string): string | undefined { * @returns Every entry. Throws on the first malformed write-up rather than skipping it. */ export async function read(options: Options): Promise { + const selected = activeAdapter.getStore() + if (selected) return selected.read() const ids = await list(options) return Promise.all(ids.map((id) => get(id, options))) } @@ -120,6 +136,8 @@ export async function read(options: Options): Promise { * @returns Entry ids. A missing directory yields an empty list. */ export async function list(options: Options): Promise { + const selected = activeAdapter.getStore() + if (selected) return selected.list() const found = await fs .readdir(path.join(options.root, dir), { withFileTypes: true }) .catch((error) => { @@ -149,6 +167,8 @@ export async function list(options: Options): Promise { * @param id - Entry id. */ export async function get(id: string, options: Options): Promise { + const selected = activeAdapter.getStore() + if (selected) return selected.get(id) const contents = await fs.readFile(path.join(options.root, toPath(id)), 'utf8') return Entry.parse(contents, { id }) } @@ -162,6 +182,8 @@ export async function get(id: string, options: Options): Promise { * @returns Paths, sorted. Empty when the entry does not exist. */ export async function files(id: string, options: Options): Promise { + const selected = activeAdapter.getStore() + if (selected) return selected.files(id) const base = path.join(options.root, toDir(id)) const found = await fs .readdir(base, { recursive: true, withFileTypes: true }) @@ -188,6 +210,8 @@ export async function write( entry: Entry.serialize.Options, options: write.Options, ): Promise { + const selected = activeAdapter.getStore() + if (selected) return selected.write(entry, options) const id = options.id ?? (await claim(entry.title, options)) const file = toPath(id) await fs.mkdir(path.join(options.root, toDir(id)), { recursive: true }) @@ -242,6 +266,8 @@ export declare namespace write { * not an error, so reconciliation stays safe to re-run. */ export async function remove(id: string, options: Options): Promise { + const selected = activeAdapter.getStore() + if (selected) return selected.remove(id) const base = path.join(options.root, toDir(id)) try { await fs.stat(base) diff --git a/src/cli/Cli.ts b/src/cli/Cli.ts index ee36d25..4689609 100644 --- a/src/cli/Cli.ts +++ b/src/cli/Cli.ts @@ -1,12 +1,15 @@ -import { Binary, Cli } from 'incur' +import { Binary, Cli, z } from 'incur' import { init } from './commands/init.js' import { list } from './commands/list.js' import { log } from './commands/log.js' import { publish } from './commands/publish.js' +import { resolve } from './commands/resolve.js' import { sync } from './commands/sync.js' import { targets } from './commands/targets.js' import * as context from './internal/context.js' import * as packageManager from './internal/packageManager.js' +import * as environmentStore from './internal/store.js' +import * as Store from '../Store.js' const globalOptionValues = new Set([ '--filter-output', @@ -17,6 +20,16 @@ const globalOptionValues = new Set([ export const cli = Cli.create('frog', { description: 'Automated friction logging for agents.', + env: z.object({ + DATABASE_URL: z.string().optional().describe('Fallback database URL for the Postgres store.'), + FROG_DATABASE_URL: z.string().optional().describe('Database URL used by the Postgres store.'), + FROG_NAMESPACE: z + .string() + .optional() + .describe('Required Postgres namespace for this consumer.'), + FROG_SCHEMA: z.string().optional().describe('Optional Postgres schema.'), + FROG_STORE: z.enum(['file', 'postgres']).optional().describe('Entry store. Defaults to file.'), + }), sync: { depth: 1, suggestions: [ @@ -30,6 +43,7 @@ export const cli = Cli.create('frog', { .command(list) .command(log) .command(publish) + .command(resolve) .command(sync) .command(targets) @@ -38,12 +52,20 @@ export async function serve( argv: string[] = process.argv.slice(2), options: Cli.serve.Options = {}, ) { - if (command(argv) !== 'init') return cli.serve(argv, options) + const selected = await environmentStore.resolve(options.env ?? process.env) + const run = async () => { + if (command(argv) !== 'init') return cli.serve(argv, options) - const { root } = await context.resolve({ cwd: option(argv, '--cwd') }) - const runner = await packageManager.resolve({ env: options.env, root }) - if (!runner) return cli.serve(argv, options) - return Cli.create(runner).command(init).serve(argv, options) + const { root } = await context.resolve({ cwd: option(argv, '--cwd') }) + const runner = await packageManager.resolve({ env: options.env, root }) + if (!runner) return cli.serve(argv, options) + return Cli.create(runner).command(init).serve(argv, options) + } + try { + return selected ? await Store.withAdapter(selected.adapter, run) : await run() + } finally { + await selected?.close() + } } export default cli diff --git a/src/cli/commands/list.ts b/src/cli/commands/list.ts index 6f98464..eae79e8 100644 --- a/src/cli/commands/list.ts +++ b/src/cli/commands/list.ts @@ -41,6 +41,12 @@ export const list = Cli.create('list', { async run(c) { const { root } = await context.resolve({ cwd: c.options.cwd }) + if (c.options.since && Store.activeName() !== 'file') + return c.error({ + code: 'STORE_UNSUPPORTED_OPTION', + message: '`--since` is available only with the repository file store.', + }) + // Both `c.error` calls stay at the top level of `run`. See `internal/attempt.ts` for why. const entries = await attempt(Store.read({ root })) if (!entries.ok) diff --git a/src/cli/commands/log.ts b/src/cli/commands/log.ts index 0d178fa..87e0566 100644 --- a/src/cli/commands/log.ts +++ b/src/cli/commands/log.ts @@ -99,8 +99,9 @@ export const log = Cli.create('log', { output: z.object({ artifacts: z .string() + .optional() .describe('Directory for reproduction files. Not created until something writes there.'), - file: z.string().describe('Path of the entry, relative to the repository root.'), + file: z.string().describe('Store location of the entry.'), id: z.string(), issue: z.string().optional().describe('Linked issue, when --publish filed one.'), title: z.string(), @@ -112,6 +113,13 @@ export const log = Cli.create('log', { async run(c) { const { config, repo, root } = await context.resolve({ cwd: c.options.cwd }) const interactive = prompt.interactive() + const opensEditor = c.options.open ?? (interactive && !c.options.body) + if (opensEditor && Store.activeName() !== 'file') + return c.error({ + code: 'STORE_UNSUPPORTED_OPTION', + message: + '`--open` is available only with the repository file store. Pass `--body` instead.', + }) // Piped input carries the whole entry, shaped like a commit message. It keeps `log` usable // without a terminal, where a prompt cannot run at all. @@ -145,9 +153,10 @@ export const log = Cli.create('log', { const ownTarget = !c.options.target || (repo !== undefined && targetRepo === repo) // Always load this repository's configured form from disk so a supplied body cannot bypass it. - const own = ownTarget - ? await attempt(form.own(root, { named: config.inbound.template })) - : undefined + const own = + ownTarget && Store.activeName() === 'file' + ? await attempt(form.own(root, { named: config.inbound.template })) + : undefined // Scaffold from the target's own issue form rather than from Frog's sections. An upstream project // judges a report against its own form. Fetched only when the answers would be used. Never fatal: @@ -267,7 +276,12 @@ export const log = Cli.create('log', { if (!c.options.publish) return c.ok( - { artifacts: Store.toArtifacts(id), file, id, title }, + { + ...(Store.activeName() === 'file' ? { artifacts: Store.toArtifacts(id) } : {}), + file, + id, + title, + }, { cta: { commands: [ diff --git a/src/cli/commands/publish.ts b/src/cli/commands/publish.ts index ed36f9b..4e3ad6a 100644 --- a/src/cli/commands/publish.ts +++ b/src/cli/commands/publish.ts @@ -85,6 +85,7 @@ export const publish = Cli.create('publish', { return c.ok({ commented: [], committed: false, created: [], deferred, unlabelled: [] }) if ( + Store.activeName() === 'file' && publishable.some((entry) => !entry.issue) && c.options.commit !== false && !c.options.dryRun && @@ -245,7 +246,13 @@ export const publish = Cli.create('publish', { // One commit, however many destinations were involved. const commit = await attempt( (async () => { - if (c.options.commit === false || c.options.dryRun || written.length === 0) return false + if ( + Store.activeName() !== 'file' || + c.options.commit === false || + c.options.dryRun || + written.length === 0 + ) + return false await Git.add(written, { cwd: root }) return Git.commit('chore: sync friction log', { cwd: root, files: written }) })(), diff --git a/src/cli/commands/resolve.test.ts b/src/cli/commands/resolve.test.ts new file mode 100644 index 0000000..b9ad6e3 --- /dev/null +++ b/src/cli/commands/resolve.test.ts @@ -0,0 +1,21 @@ +import * as cli from '../../../test/cli.js' +import * as helpers from '../../../test/helpers.js' +import * as Store from '../../Store.js' + +test('behavior: removes one resolved entry', async () => { + const cwd = await helpers.repo() + await Store.write( + { body: 'The workaround is no longer needed.', severity: 'minor', title: 'Resolved' }, + { id: 'resolved', root: cwd }, + ) + + await expect(cli.data(['resolve', 'resolved', '--cwd', cwd])).resolves.toEqual({ + id: 'resolved', + removed: true, + }) + await expect(Store.list({ root: cwd })).resolves.toEqual([]) + await expect(cli.data(['resolve', 'resolved', '--cwd', cwd])).resolves.toEqual({ + id: 'resolved', + removed: false, + }) +}) diff --git a/src/cli/commands/resolve.ts b/src/cli/commands/resolve.ts new file mode 100644 index 0000000..47a4ec1 --- /dev/null +++ b/src/cli/commands/resolve.ts @@ -0,0 +1,15 @@ +import { Cli, z } from 'incur' +import * as Store from '../../Store.js' +import * as context from '../internal/context.js' + +export const resolve = Cli.create('resolve', { + description: 'Remove one resolved friction entry.', + args: z.object({ id: z.string().min(1).describe('Exact entry id from `frog list`.') }), + options: z.object({ cwd: context.cwdOption }), + examples: [{ args: { id: '20260803000000-example' }, description: 'Mark one entry resolved' }], + output: z.object({ id: z.string(), removed: z.boolean() }), + async run(c) { + const { root } = await context.resolve({ cwd: c.options.cwd }) + return { id: c.args.id, removed: await Store.remove(c.args.id, { root }) } + }, +}) diff --git a/src/cli/commands/sync.ts b/src/cli/commands/sync.ts index 4e399ed..9967c1f 100644 --- a/src/cli/commands/sync.ts +++ b/src/cli/commands/sync.ts @@ -75,7 +75,12 @@ export const sync = Cli.create('sync', { const mirrors = await attempt(Mirrors.resolve({ root })) if (!mirrors.ok) return c.error({ code: mirrors.code, message: mirrors.message }) - if (c.options.commit !== false && !c.options.dryRun && !(await Git.identity({ cwd: root }))) + if ( + Store.activeName() === 'file' && + c.options.commit !== false && + !c.options.dryRun && + !(await Git.identity({ cwd: root })) + ) return c.error({ code: 'NO_GIT_IDENTITY', message: @@ -182,7 +187,8 @@ export const sync = Cli.create('sync', { updated, }) - await Git.rm(plan.remove.map(Store.toDir), { cwd: root, ignoreUnmatch: true }) + if (Store.activeName() === 'file') + await Git.rm(plan.remove.map(Store.toDir), { cwd: root, ignoreUnmatch: true }) for (const id of plan.remove) await Store.remove(id, { root }) for (const entry of [...plan.write, ...plan.clearLink]) await Store.write(entry, { id: entry.id, root }) @@ -192,7 +198,7 @@ export const sync = Cli.create('sync', { if (mirrorsChanged) touched.push(Mirrors.file) const commit = await attempt( (async () => { - if (c.options.commit === false) return false + if (Store.activeName() !== 'file' || c.options.commit === false) return false await Git.add(touched, { cwd: root }) return Git.commit('chore: sync friction log', { cwd: root, @@ -366,7 +372,8 @@ export const sync = Cli.create('sync', { // Stage before unlinking so tracked entries have their deletion recorded. The whole directory // goes, artifacts included. `ignoreUnmatch` covers entries that were never committed; those are // removed from disk below. - await Git.rm(removedIds.map(Store.toDir), { cwd: root, ignoreUnmatch: true }) + if (Store.activeName() === 'file') + await Git.rm(removedIds.map(Store.toDir), { cwd: root, ignoreUnmatch: true }) for (const id of removedIds) await Store.remove(id, { root }) for (const entry of [...plan.write, ...plan.clearLink]) @@ -377,7 +384,7 @@ export const sync = Cli.create('sync', { if (mirrorsChanged) touched.push(Mirrors.file) const commit = await attempt( (async () => { - if (c.options.commit === false) return false + if (Store.activeName() !== 'file' || c.options.commit === false) return false await Git.add(touched, { cwd: root }) return Git.commit('chore: sync friction log', { cwd: root, diff --git a/src/cli/internal/store.test.ts b/src/cli/internal/store.test.ts new file mode 100644 index 0000000..fa42aed --- /dev/null +++ b/src/cli/internal/store.test.ts @@ -0,0 +1,16 @@ +import * as store from './store.js' + +test('behavior: the file store remains the zero-configuration default', async () => { + await expect(store.resolve({})).resolves.toBeUndefined() + await expect(store.resolve({ FROG_STORE: 'file' })).resolves.toBeUndefined() +}) + +test('error: Postgres selection requires an explicit namespace and database URL', async () => { + await expect(store.resolve({ FROG_STORE: 'redis' })).rejects.toThrow('Use `file` or `postgres`') + await expect(store.resolve({ FROG_STORE: 'postgres' })).rejects.toThrow( + 'requires FROG_DATABASE_URL or DATABASE_URL', + ) + await expect( + store.resolve({ DATABASE_URL: 'postgres://localhost/example', FROG_STORE: 'postgres' }), + ).rejects.toThrow('requires FROG_NAMESPACE') +}) diff --git a/src/cli/internal/store.ts b/src/cli/internal/store.ts new file mode 100644 index 0000000..6293146 --- /dev/null +++ b/src/cli/internal/store.ts @@ -0,0 +1,44 @@ +import { createRequire } from 'node:module' +import * as PostgresStore from '../../PostgresStore.js' +import type * as Store from '../../Store.js' + +export type Environment = Record + +export type Selection = { + adapter: Store.Adapter + close(): Promise +} + +/** Resolves the optional CLI store without making a database driver a hard Frog dependency. */ +export async function resolve(env: Environment): Promise { + const kind = env['FROG_STORE']?.trim().toLowerCase() || 'file' + if (kind === 'file') return undefined + if (kind !== 'postgres') + throw new Error(`Unsupported FROG_STORE \`${kind}\`. Use \`file\` or \`postgres\`.`) + + const connectionString = env['FROG_DATABASE_URL']?.trim() || env['DATABASE_URL']?.trim() + if (!connectionString) + throw new Error('FROG_STORE=postgres requires FROG_DATABASE_URL or DATABASE_URL.') + const namespace = env['FROG_NAMESPACE']?.trim() + if (!namespace) throw new Error('FROG_STORE=postgres requires FROG_NAMESPACE.') + + const require = createRequire(import.meta.url) + let Pool: new (options: { connectionString: string }) => PostgresStore.Client & { + end(): Promise + } + try { + ;({ Pool } = require('pg') as { Pool: typeof Pool }) + } catch (error) { + throw new Error('The Postgres CLI store requires the optional `pg` package.', { cause: error }) + } + const client = new Pool({ connectionString }) + const schema = env['FROG_SCHEMA']?.trim() + return { + adapter: PostgresStore.adapter({ + client, + namespace, + ...(schema ? { schema } : {}), + }), + close: () => client.end(), + } +} From c0ddea2d3167160e68334fac3767c34e7d3ca786 Mon Sep 17 00:00:00 2001 From: slokh Date: Mon, 3 Aug 2026 20:53:43 -0400 Subject: [PATCH 05/26] Simplify Postgres store setup --- .../friction.md | 24 ++++ .changeset/calm-frogs-store.md | 3 +- README.md | 57 ++++----- src/PostgresStore.test.ts | 110 ++---------------- src/PostgresStore.ts | 1 + src/Store.test.ts | 3 + src/Store.ts | 12 +- src/cli/Cli.ts | 11 +- src/cli/commands/migrate.test.ts | 29 +++++ src/cli/commands/migrate.ts | 10 ++ src/cli/internal/store.test.ts | 37 ++++-- src/cli/internal/store.ts | 46 +++++--- test/postgres.ts | 96 +++++++++++++++ test/storeContract.ts | 46 ++++++++ 14 files changed, 320 insertions(+), 165 deletions(-) create mode 100644 .agents/friction-log/20260803205214-pinned-pnpm-shim/friction.md create mode 100644 src/cli/commands/migrate.test.ts create mode 100644 src/cli/commands/migrate.ts create mode 100644 test/postgres.ts create mode 100644 test/storeContract.ts diff --git a/.agents/friction-log/20260803205214-pinned-pnpm-shim/friction.md b/.agents/friction-log/20260803205214-pinned-pnpm-shim/friction.md new file mode 100644 index 0000000..a7f32dd --- /dev/null +++ b/.agents/friction-log/20260803205214-pinned-pnpm-shim/friction.md @@ -0,0 +1,24 @@ +--- +title: 'Pinned pnpm shim points to a missing binary' +severity: 'minor' +--- + +## Expected Behavior + +Running pnpm check uses the packageManager-pinned pnpm version. + +## Current Behavior + +The user-level pnpm shim exits with ENOENT because its .tools binary is missing, while corepack pnpm works. + +## Possible Solution + +Make the shim repair itself or document corepack pnpm as the reliable repository entrypoint. + +## Minimal Reproducible Example + +From the repository root, run pnpm check. + +## Context + +This interrupted the standard verification workflow and required switching runners. diff --git a/.changeset/calm-frogs-store.md b/.changeset/calm-frogs-store.md index 15d85b1..de8d59b 100644 --- a/.changeset/calm-frogs-store.md +++ b/.changeset/calm-frogs-store.md @@ -3,4 +3,5 @@ --- Add a public friction-store contract, a storage-independent `FrictionLog` API, and an optional -Postgres adapter while preserving the repository file store as the default. +Postgres adapter while preserving the repository file store as the default. `DATABASE_URL` +automatically selects Postgres for CLI commands, and `frog migrate` prepares the selected store. diff --git a/README.md b/README.md index 7d2e25f..b2cad56 100644 --- a/README.md +++ b/README.md @@ -180,17 +180,28 @@ ships a reproduction. Exits 1 on an entry that fails to parse, so it doubles as frog list ``` -### Embed Frog with another store +### Store Logs in Postgres -Frog's CLI keeps the repository file store as its default. Applications can use the same entry format -and lifecycle with another store by constructing `FrictionLog` with a store adapter. Omitting `store` -uses `.agents/friction-log/`, preserving the normal behavior. +Frog stores entries in `.agents/friction-log/` by default. Set the conventional `DATABASE_URL` to use +Postgres instead, then run the idempotent migration once: + +```sh +DATABASE_URL=postgres://... frog migrate +DATABASE_URL=postgres://... frog list +``` + +Install `pg` beside Frog when using Postgres. `FROG_NAMESPACE` can isolate several consumers in one +database (it defaults to `default`), and `FROG_SCHEMA` can place the table in a specific schema. Set +`FROG_STORE=file` to keep using repository files when `DATABASE_URL` is present. + +Applications use the same store through the programmatic API: ```ts import { FrictionLog, PostgresStore } from 'frog' import { Pool } from 'pg' const pool = new Pool({ connectionString: process.env.DATABASE_URL }) +await PostgresStore.migrate({ client: pool, namespace: 'support-agent' }) const store = PostgresStore.adapter({ client: pool, namespace: 'support-agent' }) const frog = new FrictionLog({ store }) @@ -204,38 +215,10 @@ const result = await frog.record({ const unresolved = await frog.records() // canonical entries with deduplicated occurrence counts ``` -Run `PostgresStore.migrate({ client, namespace })` from the consumer's migration process before using -the adapter. It creates one `frog_entries` table; `namespace` isolates independent applications sharing -that table. The adapter accepts the small `query` interface implemented by `pg` pools and transaction -clients, so Frog does not install a database driver or own connection credentials. With no `schema`, -queries follow the client's current Postgres search path. Pass an explicit `schema` to create and fully -qualify the table there. - -Every store implements the exported `FrictionStore` contract. Custom stores can retain entries in a -remote service, SQLite, or another database. An adapter may provide atomic `record` behavior; otherwise -`FrictionLog` supplies the file store's normalized-title deduplication. Consumer-defined `context` is -stored without interpretation and is never needed by Frog's core behavior. - -The CLI can use the same adapter. The repository file store remains the default: - -```sh -frog list -``` - -Select Postgres through environment variables when the application already owns the table migration: - -```sh -FROG_STORE=postgres \ -FROG_NAMESPACE=my-application \ -DATABASE_URL=postgres://... \ -frog list -``` - -`FROG_DATABASE_URL` overrides the conventional `DATABASE_URL`, and `FROG_SCHEMA` optionally qualifies -the table. Install `pg` beside Frog only when selecting Postgres. `frog log`, `frog list`, and `frog -resolve ` then operate on that namespace; no variables or driver are required for the default file -store. Repository-only features such as `list --since`, editing an entry with `log --open`, and artifact -directories remain available only for the file store. +Every store implements the exported `FrictionStore` contract and preserves the same `Entry` fields. +Storage metadata such as occurrence counts stays outside that entry schema. Custom adapters can use a +remote service, SQLite, or another database. Repository-only features such as artifacts, `list --since`, +and `log --open` remain available only with the file store. ### Logging Upstream @@ -281,7 +264,9 @@ Commands: init Create the friction log, config, and issue form. list List entries with their state. log Write a friction entry. + migrate Create or upgrade the selected store. publish Report pending entries as GitHub issues. + resolve Remove one resolved friction entry. sync Reconcile entries against issue state. targets List dependencies that accept friction reports. diff --git a/src/PostgresStore.test.ts b/src/PostgresStore.test.ts index 4ed012f..7e405d0 100644 --- a/src/PostgresStore.test.ts +++ b/src/PostgresStore.test.ts @@ -1,100 +1,8 @@ import * as Entry from './Entry.js' import { FrictionLog } from './FrictionLog.js' import * as PostgresStore from './PostgresStore.js' - -type Stored = { contents: string; dedupeKey: string; id: string; occurrences: number } - -class FakeClient implements PostgresStore.Client { - readonly queries: string[] = [] - readonly rows = new Map() - - async query = Record>( - text: string, - values: readonly unknown[] = [], - ): Promise<{ rowCount: number; rows: T[] }> { - this.queries.push(text) - if (text.startsWith('CREATE ')) return { rowCount: 0, rows: [] } - - const namespace = String(values[0]) - const key = (id: string) => `${namespace}\u0000${id}` - if (text.includes('ON CONFLICT(namespace, dedupe_key)')) { - const [, rawId, rawDedupe, rawContents] = values - const id = String(rawId) - const dedupeKey = String(rawDedupe) - const existing = [...this.rows.entries()].find( - ([storedKey, row]) => - storedKey.startsWith(`${namespace}\u0000`) && row.dedupeKey === dedupeKey, - )?.[1] - if (existing) { - existing.occurrences += 1 - return { - rowCount: 1, - rows: [ - { - contents: existing.contents, - created: false, - id: existing.id, - occurrence_count: existing.occurrences, - } as unknown as T, - ], - } - } - const stored = { contents: String(rawContents), dedupeKey, id, occurrences: 1 } - this.rows.set(key(id), stored) - return { - rowCount: 1, - rows: [ - { contents: stored.contents, created: true, id, occurrence_count: 1 } as unknown as T, - ], - } - } - if (text.startsWith('INSERT INTO')) { - const [, rawId, rawDedupe, rawContents] = values - const id = String(rawId) - const previous = this.rows.get(key(id)) - this.rows.set(key(id), { - contents: String(rawContents), - dedupeKey: String(rawDedupe), - id, - occurrences: previous?.occurrences ?? 1, - }) - return { rowCount: 1, rows: [] } - } - if (text.startsWith('SELECT id, contents')) { - const selected = - typeof values[1] === 'string' - ? [this.rows.get(key(values[1]))].filter(Boolean) - : [...this.rows.entries()] - .filter(([storedKey]) => storedKey.startsWith(`${namespace}\u0000`)) - .map(([, row]) => row) - return { - rowCount: selected.length, - rows: selected.map( - (row) => - ({ - contents: row!.contents, - id: row!.id, - occurrence_count: row!.occurrences, - }) as unknown as T, - ), - } - } - if (text.startsWith('SELECT id FROM')) { - const selected = [...this.rows.entries()].filter(([storedKey]) => - storedKey.startsWith(`${namespace}\u0000`), - ) - return { - rowCount: selected.length, - rows: selected.map(([, row]) => ({ id: row.id }) as unknown as T), - } - } - if (text.startsWith('DELETE FROM')) { - const removed = this.rows.delete(key(String(values[1]))) - return { rowCount: removed ? 1 : 0, rows: [] } - } - throw new Error(`Unhandled SQL: ${text}`) - } -} +import { FakePostgresClient } from '../test/postgres.js' +import { storeContract } from '../test/storeContract.js' const friction = { body: 'The tool required an unnecessary workaround.', @@ -105,7 +13,7 @@ const friction = { describe('PostgresStore', () => { test('behavior: migration is explicit, namespaced, and idempotent SQL', async () => { - const client = new FakeClient() + const client = new FakePostgresClient() await PostgresStore.migrate({ client, namespace: 'unused', schema: 'frog' }) expect(client.queries).toHaveLength(2) expect(client.queries[0]).toBe('CREATE SCHEMA IF NOT EXISTS "frog"') @@ -114,7 +22,7 @@ describe('PostgresStore', () => { }) test('behavior: an omitted schema follows the client search path', async () => { - const client = new FakeClient() + const client = new FakePostgresClient() await PostgresStore.migrate({ client, namespace: 'unused' }) expect(client.queries).toHaveLength(1) expect(client.queries[0]).toContain('CREATE TABLE IF NOT EXISTS "frog_entries"') @@ -127,7 +35,7 @@ describe('PostgresStore', () => { }) test('behavior: records, deduplicates, updates, lists, and removes through the public API', async () => { - const client = new FakeClient() + const client = new FakePostgresClient() const log = new FrictionLog({ store: PostgresStore.adapter({ client, namespace: 'consumer-a' }), }) @@ -148,7 +56,7 @@ describe('PostgresStore', () => { }) test('behavior: namespaces isolate consumers and force preserves intentional duplicates', async () => { - const client = new FakeClient() + const client = new FakePostgresClient() const first = new FrictionLog({ store: PostgresStore.adapter({ client, namespace: 'one' }) }) const second = new FrictionLog({ store: PostgresStore.adapter({ client, namespace: 'two' }) }) @@ -162,7 +70,7 @@ describe('PostgresStore', () => { test('error: rejects unsafe schema names before issuing SQL', () => { expect(() => PostgresStore.adapter({ - client: new FakeClient(), + client: new FakePostgresClient(), namespace: 'one', schema: 'public; DROP TABLE users', }), @@ -174,3 +82,7 @@ describe('PostgresStore', () => { expect(Entry.parse(serialized, { id: 'one' }).context).toEqual(friction.context) }) }) + +storeContract('Postgres', async () => + PostgresStore.adapter({ client: new FakePostgresClient(), namespace: 'contract' }), +) diff --git a/src/PostgresStore.ts b/src/PostgresStore.ts index b6df8ce..343a889 100644 --- a/src/PostgresStore.ts +++ b/src/PostgresStore.ts @@ -70,6 +70,7 @@ export function adapter(options: Options): FrictionLog.Adapter { const store: FrictionLog.Adapter = { name: 'postgres', + migrate: () => migrate(options), async read() { const result = await client.query( `SELECT id, contents, occurrence_count FROM ${table} WHERE namespace = $1 ORDER BY id`, diff --git a/src/Store.test.ts b/src/Store.test.ts index 9c63688..199e399 100644 --- a/src/Store.test.ts +++ b/src/Store.test.ts @@ -1,11 +1,14 @@ import fs from 'node:fs/promises' import path from 'node:path' import { tmpdir, writeFile } from '../test/helpers.js' +import { storeContract } from '../test/storeContract.js' import type * as Entry from './Entry.js' import * as Store from './Store.js' const entry = "---\ntitle: 'Filters are ignored'\n---\n\nBody.\n" +storeContract('file', async () => Store.adapter({ root: await tmpdir() })) + /** Writes an entry's write-up, creating its directory. */ function write(id: string, root: string, contents = entry) { return writeFile(Store.toPath(id), contents, root) diff --git a/src/Store.ts b/src/Store.ts index ce33ff7..d4f36b5 100644 --- a/src/Store.ts +++ b/src/Store.ts @@ -30,13 +30,15 @@ export type AdapterWriteOptions = { export type Adapter = { /** Stable adapter name for diagnostics. */ readonly name: string + /** Creates or upgrades adapter-owned storage, when required. Safe to call repeatedly. */ + migrate?(): Promise /** Lists every entry in stable id order. */ read(): Promise /** Lists entry ids in stable order. */ list(): Promise /** Reads one entry. */ get(id: string): Promise - /** Writes an entry, optionally replacing a known id. */ + /** Writes an entry, optionally replacing a known id. Every canonical entry field must round trip. */ write(entry: Entry.serialize.Options, options?: AdapterWriteOptions): Promise /** Removes an entry and reports whether it existed. */ remove(id: string): Promise @@ -54,6 +56,14 @@ export function activeName(): string { return activeAdapter.getStore()?.name ?? 'file' } +/** Migrates the active adapter, returning whether it owns a migration. The file store needs none. */ +export async function migrate(): Promise { + const store = activeAdapter.getStore() + if (!store?.migrate) return false + await store.migrate() + return true +} + /** Binds the existing repository-file store to one root. */ export function adapter(options: Options): Adapter { return { diff --git a/src/cli/Cli.ts b/src/cli/Cli.ts index 4689609..caab91f 100644 --- a/src/cli/Cli.ts +++ b/src/cli/Cli.ts @@ -2,6 +2,7 @@ import { Binary, Cli, z } from 'incur' import { init } from './commands/init.js' import { list } from './commands/list.js' import { log } from './commands/log.js' +import { migrate } from './commands/migrate.js' import { publish } from './commands/publish.js' import { resolve } from './commands/resolve.js' import { sync } from './commands/sync.js' @@ -21,14 +22,13 @@ const globalOptionValues = new Set([ export const cli = Cli.create('frog', { description: 'Automated friction logging for agents.', env: z.object({ - DATABASE_URL: z.string().optional().describe('Fallback database URL for the Postgres store.'), - FROG_DATABASE_URL: z.string().optional().describe('Database URL used by the Postgres store.'), - FROG_NAMESPACE: z + DATABASE_URL: z .string() .optional() - .describe('Required Postgres namespace for this consumer.'), + .describe('Postgres URL. Its presence selects the Postgres store.'), + FROG_NAMESPACE: z.string().optional().describe('Postgres namespace. Defaults to `default`.'), FROG_SCHEMA: z.string().optional().describe('Optional Postgres schema.'), - FROG_STORE: z.enum(['file', 'postgres']).optional().describe('Entry store. Defaults to file.'), + FROG_STORE: z.enum(['file', 'postgres']).optional().describe('Override the inferred entry store.'), }), sync: { depth: 1, @@ -42,6 +42,7 @@ export const cli = Cli.create('frog', { .command(init) .command(list) .command(log) + .command(migrate) .command(publish) .command(resolve) .command(sync) diff --git a/src/cli/commands/migrate.test.ts b/src/cli/commands/migrate.test.ts new file mode 100644 index 0000000..2c589d3 --- /dev/null +++ b/src/cli/commands/migrate.test.ts @@ -0,0 +1,29 @@ +import * as cli from '../../../test/cli.js' +import * as Store from '../../Store.js' + +test('behavior: the file store reports that it needs no migration', async () => { + await expect(cli.data(['migrate'])).resolves.toEqual({ migrated: false, store: 'file' }) +}) + +test('behavior: delegates migration to the selected store', async () => { + let calls = 0 + const adapter: Store.Adapter = { + name: 'test', + migrate: async () => { + calls++ + }, + read: async () => [], + list: async () => [], + get: async () => { + throw new Error('unused') + }, + write: async () => ({ file: 'unused', id: 'unused' }), + remove: async () => false, + files: async () => [], + } + + await Store.withAdapter(adapter, async () => { + await expect(cli.data(['migrate'])).resolves.toEqual({ migrated: true, store: 'test' }) + }) + expect(calls).toBe(1) +}) diff --git a/src/cli/commands/migrate.ts b/src/cli/commands/migrate.ts new file mode 100644 index 0000000..9eb78cb --- /dev/null +++ b/src/cli/commands/migrate.ts @@ -0,0 +1,10 @@ +import { Cli, z } from 'incur' +import * as Store from '../../Store.js' + +export const migrate = Cli.create('migrate', { + description: 'Create or upgrade the selected store.', + output: z.object({ migrated: z.boolean(), store: z.string() }), + async run() { + return { migrated: await Store.migrate(), store: Store.activeName() } + }, +}) diff --git a/src/cli/internal/store.test.ts b/src/cli/internal/store.test.ts index fa42aed..52aadeb 100644 --- a/src/cli/internal/store.test.ts +++ b/src/cli/internal/store.test.ts @@ -1,16 +1,33 @@ import * as store from './store.js' test('behavior: the file store remains the zero-configuration default', async () => { - await expect(store.resolve({})).resolves.toBeUndefined() - await expect(store.resolve({ FROG_STORE: 'file' })).resolves.toBeUndefined() + expect(store.configuration({})).toEqual({ kind: 'file' }) + expect( + store.configuration({ DATABASE_URL: 'postgres://localhost/example', FROG_STORE: 'file' }), + ).toEqual({ kind: 'file' }) }) -test('error: Postgres selection requires an explicit namespace and database URL', async () => { - await expect(store.resolve({ FROG_STORE: 'redis' })).rejects.toThrow('Use `file` or `postgres`') - await expect(store.resolve({ FROG_STORE: 'postgres' })).rejects.toThrow( - 'requires FROG_DATABASE_URL or DATABASE_URL', - ) - await expect( - store.resolve({ DATABASE_URL: 'postgres://localhost/example', FROG_STORE: 'postgres' }), - ).rejects.toThrow('requires FROG_NAMESPACE') +test('behavior: DATABASE_URL selects Postgres with an overridable namespace', () => { + expect(store.configuration({ DATABASE_URL: 'postgres://localhost/example' })).toEqual({ + connectionString: 'postgres://localhost/example', + kind: 'postgres', + namespace: 'default', + }) + expect( + store.configuration({ + DATABASE_URL: 'postgres://localhost/example', + FROG_NAMESPACE: 'agent', + FROG_SCHEMA: 'private', + }), + ).toEqual({ + connectionString: 'postgres://localhost/example', + kind: 'postgres', + namespace: 'agent', + schema: 'private', + }) +}) + +test('error: explicit Postgres selection still requires a database URL', () => { + expect(() => store.configuration({ FROG_STORE: 'redis' })).toThrow('Use `file` or `postgres`') + expect(() => store.configuration({ FROG_STORE: 'postgres' })).toThrow('requires DATABASE_URL') }) diff --git a/src/cli/internal/store.ts b/src/cli/internal/store.ts index 6293146..564441a 100644 --- a/src/cli/internal/store.ts +++ b/src/cli/internal/store.ts @@ -9,18 +9,39 @@ export type Selection = { close(): Promise } -/** Resolves the optional CLI store without making a database driver a hard Frog dependency. */ -export async function resolve(env: Environment): Promise { - const kind = env['FROG_STORE']?.trim().toLowerCase() || 'file' - if (kind === 'file') return undefined +export type Configuration = + | { kind: 'file' } + | { + connectionString: string + kind: 'postgres' + namespace: string + schema?: string | undefined + } + +/** Infers the store from conventional environment variables without opening a connection. */ +export function configuration(env: Environment): Configuration { + const connectionString = env['DATABASE_URL']?.trim() + const requested = env['FROG_STORE']?.trim().toLowerCase() + const kind = requested || (connectionString ? 'postgres' : 'file') + + if (kind === 'file') return { kind } if (kind !== 'postgres') throw new Error(`Unsupported FROG_STORE \`${kind}\`. Use \`file\` or \`postgres\`.`) + if (!connectionString) throw new Error('The Postgres store requires DATABASE_URL.') + + const schema = env['FROG_SCHEMA']?.trim() + return { + connectionString, + kind, + namespace: env['FROG_NAMESPACE']?.trim() || 'default', + ...(schema ? { schema } : {}), + } +} - const connectionString = env['FROG_DATABASE_URL']?.trim() || env['DATABASE_URL']?.trim() - if (!connectionString) - throw new Error('FROG_STORE=postgres requires FROG_DATABASE_URL or DATABASE_URL.') - const namespace = env['FROG_NAMESPACE']?.trim() - if (!namespace) throw new Error('FROG_STORE=postgres requires FROG_NAMESPACE.') +/** Resolves the optional CLI store without making a database driver a hard Frog dependency. */ +export async function resolve(env: Environment): Promise { + const selected = configuration(env) + if (selected.kind === 'file') return undefined const require = createRequire(import.meta.url) let Pool: new (options: { connectionString: string }) => PostgresStore.Client & { @@ -31,13 +52,12 @@ export async function resolve(env: Environment): Promise } catch (error) { throw new Error('The Postgres CLI store requires the optional `pg` package.', { cause: error }) } - const client = new Pool({ connectionString }) - const schema = env['FROG_SCHEMA']?.trim() + const client = new Pool({ connectionString: selected.connectionString }) return { adapter: PostgresStore.adapter({ client, - namespace, - ...(schema ? { schema } : {}), + namespace: selected.namespace, + ...(selected.schema ? { schema: selected.schema } : {}), }), close: () => client.end(), } diff --git a/test/postgres.ts b/test/postgres.ts new file mode 100644 index 0000000..7402dee --- /dev/null +++ b/test/postgres.ts @@ -0,0 +1,96 @@ +import type * as PostgresStore from '../src/PostgresStore.js' + +type Stored = { contents: string; dedupeKey: string; id: string; occurrences: number } + +/** Small behavioral Postgres client used by adapter and contract tests. */ +export class FakePostgresClient implements PostgresStore.Client { + readonly queries: string[] = [] + readonly rows = new Map() + + async query = Record>( + text: string, + values: readonly unknown[] = [], + ): Promise<{ rowCount: number; rows: T[] }> { + this.queries.push(text) + if (text.startsWith('CREATE ')) return { rowCount: 0, rows: [] } + + const namespace = String(values[0]) + const key = (id: string) => `${namespace}\u0000${id}` + if (text.includes('ON CONFLICT(namespace, dedupe_key)')) { + const [, rawId, rawDedupe, rawContents] = values + const id = String(rawId) + const dedupeKey = String(rawDedupe) + const existing = [...this.rows.entries()].find( + ([storedKey, row]) => + storedKey.startsWith(`${namespace}\u0000`) && row.dedupeKey === dedupeKey, + )?.[1] + if (existing) { + existing.occurrences += 1 + return { + rowCount: 1, + rows: [ + { + contents: existing.contents, + created: false, + id: existing.id, + occurrence_count: existing.occurrences, + } as unknown as T, + ], + } + } + const stored = { contents: String(rawContents), dedupeKey, id, occurrences: 1 } + this.rows.set(key(id), stored) + return { + rowCount: 1, + rows: [ + { contents: stored.contents, created: true, id, occurrence_count: 1 } as unknown as T, + ], + } + } + if (text.startsWith('INSERT INTO')) { + const [, rawId, rawDedupe, rawContents] = values + const id = String(rawId) + const previous = this.rows.get(key(id)) + this.rows.set(key(id), { + contents: String(rawContents), + dedupeKey: String(rawDedupe), + id, + occurrences: previous?.occurrences ?? 1, + }) + return { rowCount: 1, rows: [] } + } + if (text.startsWith('SELECT id, contents')) { + const selected = + typeof values[1] === 'string' + ? [this.rows.get(key(values[1]))].filter(Boolean) + : [...this.rows.entries()] + .filter(([storedKey]) => storedKey.startsWith(`${namespace}\u0000`)) + .map(([, row]) => row) + return { + rowCount: selected.length, + rows: selected.map( + (row) => + ({ + contents: row!.contents, + id: row!.id, + occurrence_count: row!.occurrences, + }) as unknown as T, + ), + } + } + if (text.startsWith('SELECT id FROM')) { + const selected = [...this.rows.entries()].filter(([storedKey]) => + storedKey.startsWith(`${namespace}\u0000`), + ) + return { + rowCount: selected.length, + rows: selected.map(([, row]) => ({ id: row.id }) as unknown as T), + } + } + if (text.startsWith('DELETE FROM')) { + const removed = this.rows.delete(key(String(values[1]))) + return { rowCount: removed ? 1 : 0, rows: [] } + } + throw new Error(`Unhandled SQL: ${text}`) + } +} diff --git a/test/storeContract.ts b/test/storeContract.ts new file mode 100644 index 0000000..ebc14db --- /dev/null +++ b/test/storeContract.ts @@ -0,0 +1,46 @@ +import type * as Entry from '../src/Entry.js' +import type * as Store from '../src/Store.js' + +const canonicalEntry = { + body: 'The tool required an unnecessary workaround.', + context: { + execution: 'opaque-reference', + nested: { attempts: 2, recovered: false }, + source: 'production-agent', + }, + issue: 'wevm/frog#123', + labels: ['agent', 'tooling'], + severity: 'major', + target: 'wevm/frog', + title: 'Tool result omitted its state', +} as const satisfies Entry.serialize.Options + +/** Runs the canonical persistence contract against a store implementation. */ +export function storeContract(name: string, create: () => Promise) { + describe(`${name} store contract`, () => { + test('preserves the complete canonical entry schema', async () => { + const store = await create() + const written = await store.write(canonicalEntry) + + expect(written.id).toBeTruthy() + expect(await store.get(written.id)).toEqual({ ...canonicalEntry, id: written.id }) + expect(await store.read()).toEqual([{ ...canonicalEntry, id: written.id }]) + expect(await store.list()).toEqual([written.id]) + expect(await store.files(written.id)).toEqual(expect.any(Array)) + + const updated = { + ...canonicalEntry, + body: 'The workaround is no longer required.', + context: { source: 'operator', verified: true }, + labels: ['resolved'], + severity: 'minor', + } as const satisfies Entry.serialize.Options + await store.write(updated, { id: written.id }) + expect(await store.get(written.id)).toEqual({ ...updated, id: written.id }) + + await expect(store.remove(written.id)).resolves.toBe(true) + await expect(store.remove(written.id)).resolves.toBe(false) + await expect(store.list()).resolves.toEqual([]) + }) + }) +} From ce222cc158e595eb5460563baa8af3eeea7fe964 Mon Sep 17 00:00:00 2001 From: slokh Date: Mon, 3 Aug 2026 20:55:15 -0400 Subject: [PATCH 06/26] Format CLI store configuration --- src/cli/Cli.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/cli/Cli.ts b/src/cli/Cli.ts index caab91f..1db2da2 100644 --- a/src/cli/Cli.ts +++ b/src/cli/Cli.ts @@ -28,7 +28,10 @@ export const cli = Cli.create('frog', { .describe('Postgres URL. Its presence selects the Postgres store.'), FROG_NAMESPACE: z.string().optional().describe('Postgres namespace. Defaults to `default`.'), FROG_SCHEMA: z.string().optional().describe('Optional Postgres schema.'), - FROG_STORE: z.enum(['file', 'postgres']).optional().describe('Override the inferred entry store.'), + FROG_STORE: z + .enum(['file', 'postgres']) + .optional() + .describe('Override the inferred entry store.'), }), sync: { depth: 1, From bf300bd082b895e248623d66d540d206f750d47b Mon Sep 17 00:00:00 2001 From: slokh Date: Mon, 3 Aug 2026 21:00:48 -0400 Subject: [PATCH 07/26] Resolve pnpm toolchain friction --- .../friction.md | 24 ------------------- 1 file changed, 24 deletions(-) delete mode 100644 .agents/friction-log/20260803205214-pinned-pnpm-shim/friction.md diff --git a/.agents/friction-log/20260803205214-pinned-pnpm-shim/friction.md b/.agents/friction-log/20260803205214-pinned-pnpm-shim/friction.md deleted file mode 100644 index a7f32dd..0000000 --- a/.agents/friction-log/20260803205214-pinned-pnpm-shim/friction.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -title: 'Pinned pnpm shim points to a missing binary' -severity: 'minor' ---- - -## Expected Behavior - -Running pnpm check uses the packageManager-pinned pnpm version. - -## Current Behavior - -The user-level pnpm shim exits with ENOENT because its .tools binary is missing, while corepack pnpm works. - -## Possible Solution - -Make the shim repair itself or document corepack pnpm as the reliable repository entrypoint. - -## Minimal Reproducible Example - -From the repository root, run pnpm check. - -## Context - -This interrupted the standard verification workflow and required switching runners. From 566a4b75286a5e270211bd104b27f14180e80cf5 Mon Sep 17 00:00:00 2001 From: slokh Date: Mon, 3 Aug 2026 21:19:11 -0400 Subject: [PATCH 08/26] Harden pluggable store boundaries --- .changeset/calm-frogs-store.md | 2 +- README.md | 21 +++--- package.json | 8 +++ pnpm-lock.yaml | 110 +++++++++++++++++++++++++++++++ src/FrictionLog.ts | 9 +-- src/PostgresStore.test.ts | 18 ++++- src/PostgresStore.ts | 31 ++++++--- src/Store.test.ts | 2 +- src/Store.ts | 46 +++++++++++-- src/cli/Cli.test.ts | 18 +++++ src/cli/Cli.ts | 18 +++-- src/cli/commands/list.test.ts | 19 ++++++ src/cli/commands/list.ts | 21 ++++-- src/cli/commands/log.test.ts | 25 +++++++ src/cli/commands/log.ts | 9 ++- src/cli/commands/migrate.test.ts | 2 +- src/cli/commands/migrate.ts | 2 +- src/cli/commands/publish.test.ts | 14 ++++ src/cli/commands/publish.ts | 16 ++--- src/cli/commands/sync.test.ts | 11 ++++ src/cli/commands/sync.ts | 24 +++---- src/cli/internal/store.test.ts | 17 ++--- src/cli/internal/store.ts | 12 +--- test/postgres.ts | 9 ++- test/storeContract.ts | 3 +- 25 files changed, 370 insertions(+), 97 deletions(-) create mode 100644 src/cli/Cli.test.ts diff --git a/.changeset/calm-frogs-store.md b/.changeset/calm-frogs-store.md index de8d59b..ef8e78a 100644 --- a/.changeset/calm-frogs-store.md +++ b/.changeset/calm-frogs-store.md @@ -3,5 +3,5 @@ --- Add a public friction-store contract, a storage-independent `FrictionLog` API, and an optional -Postgres adapter while preserving the repository file store as the default. `DATABASE_URL` +Postgres adapter while preserving the repository file store as the default. `FROG_DATABASE_URL` automatically selects Postgres for CLI commands, and `frog migrate` prepares the selected store. diff --git a/README.md b/README.md index b2cad56..ccdf462 100644 --- a/README.md +++ b/README.md @@ -182,17 +182,18 @@ frog list ### Store Logs in Postgres -Frog stores entries in `.agents/friction-log/` by default. Set the conventional `DATABASE_URL` to use -Postgres instead, then run the idempotent migration once: +Frog stores entries in `.agents/friction-log/` by default. Set `FROG_DATABASE_URL` to use Postgres +instead, then run the idempotent migration once: ```sh -DATABASE_URL=postgres://... frog migrate -DATABASE_URL=postgres://... frog list +FROG_DATABASE_URL=postgres://... frog migrate +FROG_DATABASE_URL=postgres://... frog list ``` -Install `pg` beside Frog when using Postgres. `FROG_NAMESPACE` can isolate several consumers in one -database (it defaults to `default`), and `FROG_SCHEMA` can place the table in a specific schema. Set -`FROG_STORE=file` to keep using repository files when `DATABASE_URL` is present. +Install `pg` beside Frog when using Postgres (and `@types/pg` in TypeScript projects). +`FROG_NAMESPACE` can isolate several consumers in one database (it defaults to `default`), and +`FROG_SCHEMA` can place the table in a specific schema. An unrelated application `DATABASE_URL` does +not change Frog's default store. Applications use the same store through the programmatic API: @@ -201,7 +202,7 @@ import { FrictionLog, PostgresStore } from 'frog' import { Pool } from 'pg' const pool = new Pool({ connectionString: process.env.DATABASE_URL }) -await PostgresStore.migrate({ client: pool, namespace: 'support-agent' }) +await PostgresStore.migrate({ client: pool }) const store = PostgresStore.adapter({ client: pool, namespace: 'support-agent' }) const frog = new FrictionLog({ store }) @@ -217,8 +218,8 @@ const unresolved = await frog.records() // canonical entries with deduplicated o Every store implements the exported `FrictionStore` contract and preserves the same `Entry` fields. Storage metadata such as occurrence counts stays outside that entry schema. Custom adapters can use a -remote service, SQLite, or another database. Repository-only features such as artifacts, `list --since`, -and `log --open` remain available only with the file store. +remote service, SQLite, or another database. Repository and GitHub automation—artifacts, `list --since`, +`log --open`, `log --publish`, `publish`, and `sync`—remains available only with the file store. ### Logging Upstream diff --git a/package.json b/package.json index 6478cba..ea70cd0 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,14 @@ "incur": "catalog:", "yaml": "catalog:" }, + "peerDependencies": { + "pg": ">=8.0.0" + }, + "peerDependenciesMeta": { + "pg": { + "optional": true + } + }, "engines": { "node": ">=22" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b982591..5c31c71 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,6 +62,9 @@ importers: incur: specifier: 'catalog:' version: 0.4.25 + pg: + specifier: '>=8.0.0' + version: 8.22.0 yaml: specifier: 'catalog:' version: 2.9.0 @@ -2207,6 +2210,40 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pg-cloudflare@1.4.0: + resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} + + pg-connection-string@2.14.0: + resolution: {integrity: sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.14.0: + resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.15.0: + resolution: {integrity: sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.22.0: + resolution: {integrity: sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2230,6 +2267,22 @@ packages: resolution: {integrity: sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==} engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + prettier@2.8.8: resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==} engines: {node: '>=10.13.0'} @@ -2313,6 +2366,10 @@ packages: spawndamnit@3.0.1: resolution: {integrity: sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg==} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} @@ -2566,6 +2623,10 @@ packages: utf-8-validate: optional: true + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + yaml@2.9.0: resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} engines: {node: '>= 14.6'} @@ -4276,6 +4337,41 @@ snapshots: pathe@2.0.3: {} + pg-cloudflare@1.4.0: + optional: true + + pg-connection-string@2.14.0: {} + + pg-int8@1.0.1: {} + + pg-pool@3.14.0(pg@8.22.0): + dependencies: + pg: 8.22.0 + + pg-protocol@1.15.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.22.0: + dependencies: + pg-connection-string: 2.14.0 + pg-pool: 3.14.0(pg@8.22.0) + pg-protocol: 1.15.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.4.0 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -4292,6 +4388,16 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + prettier@2.8.8: {} pretty-format@27.5.1: @@ -4405,6 +4511,8 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + split2@4.2.0: {} + sprintf-js@1.0.3: {} stackback@0.0.2: {} @@ -4637,6 +4745,8 @@ snapshots: ws@8.21.1: {} + xtend@4.0.2: {} + yaml@2.9.0: {} youch-core@0.3.3: diff --git a/src/FrictionLog.ts b/src/FrictionLog.ts index fdfdb45..bfc4d33 100644 --- a/src/FrictionLog.ts +++ b/src/FrictionLog.ts @@ -2,12 +2,7 @@ import * as Entry from './Entry.js' import * as Store from './Store.js' /** One canonical entry and the number of times it has been observed. */ -export type StoredEntry = { - /** The canonical entry representing the friction. */ - entry: Entry.Entry - /** Number of times this adapter has observed the friction, when tracked. */ - occurrences: number -} +export type StoredEntry = Store.StoredEntry /** Result of recording one friction occurrence. */ export type RecordResult = StoredEntry & { @@ -16,8 +11,6 @@ export type RecordResult = StoredEntry & { } export type Adapter = Store.Adapter & { - /** Optional occurrence-aware listing supplied by durable adapters. */ - records?(): Promise /** Optional atomic deduplication supplied by durable adapters. */ record?( entry: Entry.serialize.Options, diff --git a/src/PostgresStore.test.ts b/src/PostgresStore.test.ts index 7e405d0..c5780ce 100644 --- a/src/PostgresStore.test.ts +++ b/src/PostgresStore.test.ts @@ -14,7 +14,7 @@ const friction = { describe('PostgresStore', () => { test('behavior: migration is explicit, namespaced, and idempotent SQL', async () => { const client = new FakePostgresClient() - await PostgresStore.migrate({ client, namespace: 'unused', schema: 'frog' }) + await PostgresStore.migrate({ client, schema: 'frog' }) expect(client.queries).toHaveLength(2) expect(client.queries[0]).toBe('CREATE SCHEMA IF NOT EXISTS "frog"') expect(client.queries[1]).toContain('CREATE TABLE IF NOT EXISTS "frog"."frog_entries"') @@ -23,7 +23,7 @@ describe('PostgresStore', () => { test('behavior: an omitted schema follows the client search path', async () => { const client = new FakePostgresClient() - await PostgresStore.migrate({ client, namespace: 'unused' }) + await PostgresStore.migrate({ client }) expect(client.queries).toHaveLength(1) expect(client.queries[0]).toContain('CREATE TABLE IF NOT EXISTS "frog_entries"') @@ -55,6 +55,20 @@ describe('PostgresStore', () => { await expect(log.get(first.entry.id)).rejects.toBeInstanceOf(PostgresStore.NotFoundError) }) + test('behavior: updating a recorded title moves its deduplication identity', async () => { + const client = new FakePostgresClient() + const log = new FrictionLog({ + store: PostgresStore.adapter({ client, namespace: 'consumer-a' }), + }) + const first = await log.record(friction) + + await log.update(first.entry.id, { ...friction, title: 'Tool state was omitted' }) + const repeated = await log.record({ ...friction, title: 'tool state was omitted!' }) + + expect(repeated).toMatchObject({ created: false, occurrences: 2 }) + expect(repeated.entry.id).toBe(first.entry.id) + }) + test('behavior: namespaces isolate consumers and force preserves intentional duplicates', async () => { const client = new FakePostgresClient() const first = new FrictionLog({ store: PostgresStore.adapter({ client, namespace: 'one' }) }) diff --git a/src/PostgresStore.ts b/src/PostgresStore.ts index 343a889..8016a4f 100644 --- a/src/PostgresStore.ts +++ b/src/PostgresStore.ts @@ -6,7 +6,7 @@ import type * as FrictionLog from './FrictionLog.js' export type Client = { query = Record>( text: string, - values?: readonly unknown[], + values?: unknown[], ): Promise<{ /** Number of affected rows when the driver supplies it. */ rowCount?: number | null | undefined @@ -15,16 +15,20 @@ export type Client = { }> } -/** Postgres adapter configuration. */ -export type Options = { +/** Postgres schema lifecycle configuration. */ +export type MigrationOptions = { /** Pool or transaction client used for every query. */ client: Client - /** Isolates independent consumers sharing one table. */ - namespace: string /** Optional PostgreSQL schema. Omit it to use the client's current search path. */ schema?: string | undefined } +/** Postgres adapter configuration. */ +export type Options = MigrationOptions & { + /** Isolates independent consumers sharing one table. */ + namespace: string +} + type Row = { contents: string created?: boolean | undefined @@ -33,7 +37,7 @@ type Row = { } /** Creates the tables required by the Postgres adapter. Safe to call repeatedly. */ -export async function migrate(options: Options): Promise { +export async function migrate(options: MigrationOptions): Promise { const schema = options.schema === undefined ? undefined : schemaName(options.schema) const table = tableName(schema) if (schema !== undefined) await options.client.query(`CREATE SCHEMA IF NOT EXISTS "${schema}"`) @@ -100,15 +104,20 @@ export function adapter(options: Options): FrictionLog.Adapter { const id = writeOptions.id ?? newId(entry.title) const contents = Entry.serialize(entry) const dedupeKey = `entry:${id}` + const titleKey = `title:${Entry.normalizeTitle(entry.title)}` await client.query( `INSERT INTO ${table}(namespace, id, dedupe_key, contents) VALUES ($1, $2, $3, $4) ON CONFLICT(namespace, id) DO UPDATE SET + dedupe_key = CASE + WHEN ${table}.dedupe_key LIKE 'title:%' THEN $5 + ELSE ${table}.dedupe_key + END, contents = EXCLUDED.contents, updated_at = now()`, - [namespace, id, dedupeKey, contents], + [namespace, id, dedupeKey, contents, titleKey], ) - return { file: location(namespace, id), id } + return { id, location: location(namespace, id) } }, async remove(id) { const result = await client.query(`DELETE FROM ${table} WHERE namespace = $1 AND id = $2`, [ @@ -122,14 +131,16 @@ export function adapter(options: Options): FrictionLog.Adapter { }, async record(entry, recordOptions = {}) { const id = newId(entry.title) - const dedupeKey = recordOptions.force ? `forced:${id}` : Entry.normalizeTitle(entry.title) + const dedupeKey = recordOptions.force + ? `forced:${id}` + : `title:${Entry.normalizeTitle(entry.title)}` const result = await client.query( `INSERT INTO ${table}(namespace, id, dedupe_key, contents) VALUES ($1, $2, $3, $4) ON CONFLICT(namespace, dedupe_key) DO UPDATE SET occurrence_count = ${table}.occurrence_count + 1, updated_at = now() - RETURNING id, contents, occurrence_count, (xmax = 0) AS created`, + RETURNING id, contents, occurrence_count, (occurrence_count = 1) AS created`, [namespace, id, dedupeKey, Entry.serialize(entry)], ) const row = result.rows[0] diff --git a/src/Store.test.ts b/src/Store.test.ts index 199e399..473caad 100644 --- a/src/Store.test.ts +++ b/src/Store.test.ts @@ -24,7 +24,7 @@ test('behavior: an async adapter scope redirects every store operation', async ( write: async (value, options = {}) => { const id = options.id ?? 'memory-id' entries.set(id, { ...value, id }) - return { file: `memory:${id}`, id } + return { id, location: `memory:${id}` } }, remove: async (id) => entries.delete(id), files: async () => [], diff --git a/src/Store.ts b/src/Store.ts index d4f36b5..7252112 100644 --- a/src/Store.ts +++ b/src/Store.ts @@ -26,24 +26,42 @@ export type AdapterWriteOptions = { id?: string | undefined } +/** Result of writing through a storage adapter. */ +export type AdapterWriteResult = { + /** Stable entry id. */ + id: string + /** Adapter-defined location suitable for diagnostics. */ + location: string +} + +/** One canonical entry and optional storage metadata. */ +export type StoredEntry = { + /** Canonical entry payload shared by every store. */ + entry: Entry.Entry + /** Number of observations when the adapter tracks recurrence. */ + occurrences: number +} + /** Storage operations consumed by Frog's programmatic API. */ export type Adapter = { /** Stable adapter name for diagnostics. */ readonly name: string - /** Creates or upgrades adapter-owned storage, when required. Safe to call repeatedly. */ + /** Prepares adapter-owned storage, when required. Safe to call repeatedly. */ migrate?(): Promise /** Lists every entry in stable id order. */ read(): Promise + /** Lists entries with recurrence metadata, when tracked by the adapter. */ + records?(): Promise /** Lists entry ids in stable order. */ list(): Promise /** Reads one entry. */ get(id: string): Promise /** Writes an entry, optionally replacing a known id. Every canonical entry field must round trip. */ - write(entry: Entry.serialize.Options, options?: AdapterWriteOptions): Promise + write(entry: Entry.serialize.Options, options?: AdapterWriteOptions): Promise /** Removes an entry and reports whether it existed. */ remove(id: string): Promise /** Lists adapter-owned artifact locations, when the adapter supports artifacts. */ - files(id: string): Promise + files?(id: string): Promise } /** Runs store operations in one async scope through the supplied adapter. */ @@ -71,8 +89,12 @@ export function adapter(options: Options): Adapter { read: () => activeAdapter.run(undefined, () => read(options)), list: () => activeAdapter.run(undefined, () => list(options)), get: (id) => activeAdapter.run(undefined, () => get(id, options)), - write: (entry, writeOptions = {}) => - activeAdapter.run(undefined, () => write(entry, { ...writeOptions, root: options.root })), + write: async (entry, writeOptions = {}) => { + const written = await activeAdapter.run(undefined, () => + write(entry, { ...writeOptions, root: options.root }), + ) + return { id: written.id, location: written.file } + }, remove: (id) => activeAdapter.run(undefined, () => remove(id, options)), files: (id) => activeAdapter.run(undefined, () => files(id, options)), } @@ -138,6 +160,13 @@ export async function read(options: Options): Promise { return Promise.all(ids.map((id) => get(id, options))) } +/** Lists canonical entries with recurrence metadata when the active adapter tracks it. */ +export async function records(options: Options): Promise { + const selected = activeAdapter.getStore() + if (selected?.records) return selected.records() + return (await read(options)).map((entry) => ({ entry, occurrences: 1 })) +} + /** * Lists the ids of every entry, sorted. * @@ -193,7 +222,7 @@ export async function get(id: string, options: Options): Promise { */ export async function files(id: string, options: Options): Promise { const selected = activeAdapter.getStore() - if (selected) return selected.files(id) + if (selected) return selected.files?.(id) ?? [] const base = path.join(options.root, toDir(id)) const found = await fs .readdir(base, { recursive: true, withFileTypes: true }) @@ -221,7 +250,10 @@ export async function write( options: write.Options, ): Promise { const selected = activeAdapter.getStore() - if (selected) return selected.write(entry, options) + if (selected) { + const written = await selected.write(entry, options.id ? { id: options.id } : {}) + return { file: written.location, id: written.id } + } const id = options.id ?? (await claim(entry.title, options)) const file = toPath(id) await fs.mkdir(path.join(options.root, toDir(id)), { recursive: true }) diff --git a/src/cli/Cli.test.ts b/src/cli/Cli.test.ts new file mode 100644 index 0000000..e3ba0bc --- /dev/null +++ b/src/cli/Cli.test.ts @@ -0,0 +1,18 @@ +import { serve } from './Cli.js' + +test.each([['--version'], ['list', '--help'], ['--schema']])( + 'behavior: %s does not initialize the configured store', + async (...argv) => { + let output = '' + await expect( + serve(argv, { + env: { FROG_DATABASE_URL: 'postgres://driver-must-not-load' }, + exit() {}, + stdout(value) { + output += value + }, + }), + ).resolves.toBeUndefined() + expect(output).toBeTruthy() + }, +) diff --git a/src/cli/Cli.ts b/src/cli/Cli.ts index 1db2da2..8fc455e 100644 --- a/src/cli/Cli.ts +++ b/src/cli/Cli.ts @@ -19,19 +19,18 @@ const globalOptionValues = new Set([ '--token-offset', ]) +const storedCommands = new Set(['list', 'log', 'migrate', 'publish', 'resolve', 'sync']) +const introspectionOptions = new Set(['--help', '--llms', '--llms-full', '--schema', '--version']) + export const cli = Cli.create('frog', { description: 'Automated friction logging for agents.', env: z.object({ - DATABASE_URL: z + FROG_DATABASE_URL: z .string() .optional() .describe('Postgres URL. Its presence selects the Postgres store.'), FROG_NAMESPACE: z.string().optional().describe('Postgres namespace. Defaults to `default`.'), FROG_SCHEMA: z.string().optional().describe('Optional Postgres schema.'), - FROG_STORE: z - .enum(['file', 'postgres']) - .optional() - .describe('Override the inferred entry store.'), }), sync: { depth: 1, @@ -56,7 +55,9 @@ export async function serve( argv: string[] = process.argv.slice(2), options: Cli.serve.Options = {}, ) { - const selected = await environmentStore.resolve(options.env ?? process.env) + const selected = usesStore(argv) + ? await environmentStore.resolve(options.env ?? process.env) + : undefined const run = async () => { if (command(argv) !== 'init') return cli.serve(argv, options) @@ -95,3 +96,8 @@ function option(argv: readonly string[], name: string): string | undefined { } return undefined } + +function usesStore(argv: readonly string[]): boolean { + if (argv.some((value) => introspectionOptions.has(value))) return false + return argv.includes('--mcp') || storedCommands.has(command(argv) ?? '') +} diff --git a/src/cli/commands/list.test.ts b/src/cli/commands/list.test.ts index 434fe99..ec115b3 100644 --- a/src/cli/commands/list.test.ts +++ b/src/cli/commands/list.test.ts @@ -1,5 +1,8 @@ import * as cli from '../../../test/cli.js' import * as helpers from '../../../test/helpers.js' +import { FakePostgresClient } from '../../../test/postgres.js' +import { FrictionLog } from '../../FrictionLog.js' +import * as PostgresStore from '../../PostgresStore.js' import * as Store from '../../Store.js' const body = 'The filter was swallowed.' @@ -75,6 +78,22 @@ test('behavior: an empty directory lists nothing', async () => { }) }) +test('behavior: a durable store lists occurrence counts', async () => { + const store = PostgresStore.adapter({ + client: new FakePostgresClient(), + namespace: 'list-test', + }) + const log = new FrictionLog({ store }) + await log.record({ body, severity: 'minor', title: 'Repeated friction' }) + await log.record({ body, severity: 'minor', title: 'repeated friction' }) + + await Store.withAdapter(store, async () => { + expect(await cli.data(['list', '--cwd', await helpers.repo()])).toMatchObject({ + entries: [{ occurrences: 2, title: 'Repeated friction' }], + }) + }) +}) + test('behavior: filters by state', async () => { const cwd = await helpers.repo() await seed(cwd) diff --git a/src/cli/commands/list.ts b/src/cli/commands/list.ts index eae79e8..bc63906 100644 --- a/src/cli/commands/list.ts +++ b/src/cli/commands/list.ts @@ -28,6 +28,12 @@ export const list = Cli.create('list', { .optional() .describe('Reproduction files, when the entry has any. Runnable as they are.'), id: z.string(), + occurrences: z + .number() + .int() + .positive() + .optional() + .describe('Times observed, when tracked.'), issue: z.string().optional().describe('Linked issue, absent while pending.'), severity: z.string(), state: State, @@ -48,11 +54,11 @@ export const list = Cli.create('list', { }) // Both `c.error` calls stay at the top level of `run`. See `internal/attempt.ts` for why. - const entries = await attempt(Store.read({ root })) - if (!entries.ok) + const records = await attempt(Store.records({ root })) + if (!records.ok) return c.error({ - code: entries.code, - message: entries.message, + code: records.code, + message: records.message, cta: { commands: [{ command: 'list', description: 'Check that every entry parses' }], description: 'Fix the file, then:', @@ -72,7 +78,11 @@ export const list = Cli.create('list', { ? new Set(changed.value.map(Store.toId).filter((id): id is string => Boolean(id))) : undefined - const selected = entries.value + const occurrences = new Map( + records.value.map((record) => [record.entry.id, record.occurrences] as const), + ) + const selected = records.value + .map((record) => record.entry) .filter((entry) => !ids || ids.has(entry.id)) .filter( (entry) => !c.options.state || (entry.issue ? 'linked' : 'pending') === c.options.state, @@ -84,6 +94,7 @@ export const list = Cli.create('list', { const artifacts = files.filter((file) => file.startsWith(`${Store.toArtifacts(entry.id)}/`)) return { id: entry.id, + ...(Store.activeName() === 'file' ? {} : { occurrences: occurrences.get(entry.id) ?? 1 }), severity: entry.severity, state: entry.issue ? ('linked' as const) : ('pending' as const), title: entry.title, diff --git a/src/cli/commands/log.test.ts b/src/cli/commands/log.test.ts index 7b9b442..77c8366 100644 --- a/src/cli/commands/log.test.ts +++ b/src/cli/commands/log.test.ts @@ -5,11 +5,36 @@ import path from 'node:path' import * as cli from '../../../test/cli.js' import { github } from '../../../test/github.js' import * as helpers from '../../../test/helpers.js' +import { FakePostgresClient } from '../../../test/postgres.js' import * as Config from '../../Config.js' +import * as PostgresStore from '../../PostgresStore.js' import * as Store from '../../Store.js' const title = '`pnpm test -- ` ignores file filters' const body = '## Description\n\nThe filter was swallowed.' + +test('error: immediate publishing requires the file store', async () => { + const store = PostgresStore.adapter({ client: new FakePostgresClient(), namespace: 'log-test' }) + const cwd = await helpers.repo() + + await Store.withAdapter(store, async () => { + expect((await cli.error(['log', title, '--body', body, '--publish', '--cwd', cwd])).code).toBe( + 'STORE_UNSUPPORTED_OPTION', + ) + }) +}) + +test('behavior: durable-store follow-up does not suggest repository publishing', async () => { + const store = PostgresStore.adapter({ client: new FakePostgresClient(), namespace: 'log-test' }) + const cwd = await helpers.repo() + + await Store.withAdapter(store, async () => { + expect((await cli.run(['log', title, '--body', body, '--cwd', cwd])).envelope).toMatchObject({ + meta: { cta: { commands: [{ command: 'frog list' }] } }, + ok: true, + }) + }) +}) const ownForm = [ 'name: Friction', 'body:', diff --git a/src/cli/commands/log.ts b/src/cli/commands/log.ts index 87e0566..c08c273 100644 --- a/src/cli/commands/log.ts +++ b/src/cli/commands/log.ts @@ -113,6 +113,11 @@ export const log = Cli.create('log', { async run(c) { const { config, repo, root } = await context.resolve({ cwd: c.options.cwd }) const interactive = prompt.interactive() + if (c.options.publish && Store.activeName() !== 'file') + return c.error({ + code: 'STORE_UNSUPPORTED_OPTION', + message: '`--publish` is available only with the repository file store.', + }) const opensEditor = c.options.open ?? (interactive && !c.options.body) if (opensEditor && Store.activeName() !== 'file') return c.error({ @@ -286,7 +291,9 @@ export const log = Cli.create('log', { cta: { commands: [ { command: 'list', description: 'See everything recorded' }, - { command: 'publish', description: 'File it as an issue now' }, + ...(Store.activeName() === 'file' + ? [{ command: 'publish', description: 'File it as an issue now' }] + : []), ], description: 'Next:', }, diff --git a/src/cli/commands/migrate.test.ts b/src/cli/commands/migrate.test.ts index 2c589d3..47f39da 100644 --- a/src/cli/commands/migrate.test.ts +++ b/src/cli/commands/migrate.test.ts @@ -17,7 +17,7 @@ test('behavior: delegates migration to the selected store', async () => { get: async () => { throw new Error('unused') }, - write: async () => ({ file: 'unused', id: 'unused' }), + write: async () => ({ id: 'unused', location: 'unused' }), remove: async () => false, files: async () => [], } diff --git a/src/cli/commands/migrate.ts b/src/cli/commands/migrate.ts index 9eb78cb..049328d 100644 --- a/src/cli/commands/migrate.ts +++ b/src/cli/commands/migrate.ts @@ -2,7 +2,7 @@ import { Cli, z } from 'incur' import * as Store from '../../Store.js' export const migrate = Cli.create('migrate', { - description: 'Create or upgrade the selected store.', + description: 'Prepare the selected store.', output: z.object({ migrated: z.boolean(), store: z.string() }), async run() { return { migrated: await Store.migrate(), store: Store.activeName() } diff --git a/src/cli/commands/publish.test.ts b/src/cli/commands/publish.test.ts index ae0d593..17bf084 100644 --- a/src/cli/commands/publish.test.ts +++ b/src/cli/commands/publish.test.ts @@ -3,8 +3,10 @@ import path from 'node:path' import * as cli from '../../../test/cli.js' import { github } from '../../../test/github.js' import * as helpers from '../../../test/helpers.js' +import { FakePostgresClient } from '../../../test/postgres.js' import * as Config from '../../Config.js' import * as Github from '../../Github.js' +import * as PostgresStore from '../../PostgresStore.js' import * as Store from '../../Store.js' const repo = 'wevm/demo' @@ -23,6 +25,18 @@ function env(url: string): Record { return { GITHUB_API_URL: url, GITHUB_TOKEN: 'test-token' } } +test('error: repository publishing requires the file store', async () => { + const store = PostgresStore.adapter({ + client: new FakePostgresClient(), + namespace: 'publish-test', + }) + const cwd = await helpers.repo({ remote }) + + await Store.withAdapter(store, async () => { + expect((await cli.error(['publish', '--cwd', cwd])).code).toBe('STORE_UNSUPPORTED_COMMAND') + }) +}) + test('behavior: files a pending entry and writes the link back', async () => { const cwd = await helpers.repo({ remote }) const instance = await github() diff --git a/src/cli/commands/publish.ts b/src/cli/commands/publish.ts index 4e3ad6a..9c6d0f9 100644 --- a/src/cli/commands/publish.ts +++ b/src/cli/commands/publish.ts @@ -74,6 +74,13 @@ export const publish = Cli.create('publish', { async run(c) { const { config, repo, root } = await context.resolve({ cwd: c.options.cwd }) + if (Store.activeName() !== 'file') + return c.error({ + code: 'STORE_UNSUPPORTED_COMMAND', + message: + '`publish` requires the repository file store because issue reconciliation is repository-owned.', + }) + const entries = await attempt(Store.read({ root })) if (!entries.ok) return c.error({ code: entries.code, message: entries.message }) @@ -85,7 +92,6 @@ export const publish = Cli.create('publish', { return c.ok({ commented: [], committed: false, created: [], deferred, unlabelled: [] }) if ( - Store.activeName() === 'file' && publishable.some((entry) => !entry.issue) && c.options.commit !== false && !c.options.dryRun && @@ -246,13 +252,7 @@ export const publish = Cli.create('publish', { // One commit, however many destinations were involved. const commit = await attempt( (async () => { - if ( - Store.activeName() !== 'file' || - c.options.commit === false || - c.options.dryRun || - written.length === 0 - ) - return false + if (c.options.commit === false || c.options.dryRun || written.length === 0) return false await Git.add(written, { cwd: root }) return Git.commit('chore: sync friction log', { cwd: root, files: written }) })(), diff --git a/src/cli/commands/sync.test.ts b/src/cli/commands/sync.test.ts index 1b4a06d..9b0f01e 100644 --- a/src/cli/commands/sync.test.ts +++ b/src/cli/commands/sync.test.ts @@ -3,10 +3,12 @@ import path from 'node:path' import * as cli from '../../../test/cli.js' import { github } from '../../../test/github.js' import * as helpers from '../../../test/helpers.js' +import { FakePostgresClient } from '../../../test/postgres.js' import * as AppSync from '../../AppSync.js' import * as Entry from '../../Entry.js' import * as Github from '../../Github.js' import * as Mirrors from '../../Mirrors.js' +import * as PostgresStore from '../../PostgresStore.js' import * as Store from '../../Store.js' const repo = 'wevm/demo' @@ -26,6 +28,15 @@ function env(url: string): Record { return { GITHUB_API_URL: url, GITHUB_TOKEN: 'test-token' } } +test('error: repository reconciliation requires the file store', async () => { + const store = PostgresStore.adapter({ client: new FakePostgresClient(), namespace: 'sync-test' }) + const cwd = await helpers.repo({ remote }) + + await Store.withAdapter(store, async () => { + expect((await cli.error(['sync', '--cwd', cwd])).code).toBe('STORE_UNSUPPORTED_COMMAND') + }) +}) + function issueBody(id: string, body = 'Body.', severity?: Entry.Severity): string { return Github.renderBody({ body, diff --git a/src/cli/commands/sync.ts b/src/cli/commands/sync.ts index 9967c1f..0059bb8 100644 --- a/src/cli/commands/sync.ts +++ b/src/cli/commands/sync.ts @@ -70,17 +70,19 @@ export const sync = Cli.create('sync', { async run(c) { const { config, repo, root } = await context.resolve({ cwd: c.options.cwd }) + if (Store.activeName() !== 'file') + return c.error({ + code: 'STORE_UNSUPPORTED_COMMAND', + message: + '`sync` requires the repository file store because reconciliation mirrors are repository-owned.', + }) + const entries = await attempt(Store.read({ root })) if (!entries.ok) return c.error({ code: entries.code, message: entries.message }) const mirrors = await attempt(Mirrors.resolve({ root })) if (!mirrors.ok) return c.error({ code: mirrors.code, message: mirrors.message }) - if ( - Store.activeName() === 'file' && - c.options.commit !== false && - !c.options.dryRun && - !(await Git.identity({ cwd: root })) - ) + if (c.options.commit !== false && !c.options.dryRun && !(await Git.identity({ cwd: root }))) return c.error({ code: 'NO_GIT_IDENTITY', message: @@ -187,8 +189,7 @@ export const sync = Cli.create('sync', { updated, }) - if (Store.activeName() === 'file') - await Git.rm(plan.remove.map(Store.toDir), { cwd: root, ignoreUnmatch: true }) + await Git.rm(plan.remove.map(Store.toDir), { cwd: root, ignoreUnmatch: true }) for (const id of plan.remove) await Store.remove(id, { root }) for (const entry of [...plan.write, ...plan.clearLink]) await Store.write(entry, { id: entry.id, root }) @@ -198,7 +199,7 @@ export const sync = Cli.create('sync', { if (mirrorsChanged) touched.push(Mirrors.file) const commit = await attempt( (async () => { - if (Store.activeName() !== 'file' || c.options.commit === false) return false + if (c.options.commit === false) return false await Git.add(touched, { cwd: root }) return Git.commit('chore: sync friction log', { cwd: root, @@ -372,8 +373,7 @@ export const sync = Cli.create('sync', { // Stage before unlinking so tracked entries have their deletion recorded. The whole directory // goes, artifacts included. `ignoreUnmatch` covers entries that were never committed; those are // removed from disk below. - if (Store.activeName() === 'file') - await Git.rm(removedIds.map(Store.toDir), { cwd: root, ignoreUnmatch: true }) + await Git.rm(removedIds.map(Store.toDir), { cwd: root, ignoreUnmatch: true }) for (const id of removedIds) await Store.remove(id, { root }) for (const entry of [...plan.write, ...plan.clearLink]) @@ -384,7 +384,7 @@ export const sync = Cli.create('sync', { if (mirrorsChanged) touched.push(Mirrors.file) const commit = await attempt( (async () => { - if (Store.activeName() !== 'file' || c.options.commit === false) return false + if (c.options.commit === false) return false await Git.add(touched, { cwd: root }) return Git.commit('chore: sync friction log', { cwd: root, diff --git a/src/cli/internal/store.test.ts b/src/cli/internal/store.test.ts index 52aadeb..b58f0b2 100644 --- a/src/cli/internal/store.test.ts +++ b/src/cli/internal/store.test.ts @@ -2,20 +2,20 @@ import * as store from './store.js' test('behavior: the file store remains the zero-configuration default', async () => { expect(store.configuration({})).toEqual({ kind: 'file' }) - expect( - store.configuration({ DATABASE_URL: 'postgres://localhost/example', FROG_STORE: 'file' }), - ).toEqual({ kind: 'file' }) + expect(store.configuration({ DATABASE_URL: 'postgres://localhost/example' })).toEqual({ + kind: 'file', + }) }) -test('behavior: DATABASE_URL selects Postgres with an overridable namespace', () => { - expect(store.configuration({ DATABASE_URL: 'postgres://localhost/example' })).toEqual({ +test('behavior: FROG_DATABASE_URL selects Postgres with an overridable namespace', () => { + expect(store.configuration({ FROG_DATABASE_URL: 'postgres://localhost/example' })).toEqual({ connectionString: 'postgres://localhost/example', kind: 'postgres', namespace: 'default', }) expect( store.configuration({ - DATABASE_URL: 'postgres://localhost/example', + FROG_DATABASE_URL: 'postgres://localhost/example', FROG_NAMESPACE: 'agent', FROG_SCHEMA: 'private', }), @@ -26,8 +26,3 @@ test('behavior: DATABASE_URL selects Postgres with an overridable namespace', () schema: 'private', }) }) - -test('error: explicit Postgres selection still requires a database URL', () => { - expect(() => store.configuration({ FROG_STORE: 'redis' })).toThrow('Use `file` or `postgres`') - expect(() => store.configuration({ FROG_STORE: 'postgres' })).toThrow('requires DATABASE_URL') -}) diff --git a/src/cli/internal/store.ts b/src/cli/internal/store.ts index 564441a..7653812 100644 --- a/src/cli/internal/store.ts +++ b/src/cli/internal/store.ts @@ -20,19 +20,13 @@ export type Configuration = /** Infers the store from conventional environment variables without opening a connection. */ export function configuration(env: Environment): Configuration { - const connectionString = env['DATABASE_URL']?.trim() - const requested = env['FROG_STORE']?.trim().toLowerCase() - const kind = requested || (connectionString ? 'postgres' : 'file') - - if (kind === 'file') return { kind } - if (kind !== 'postgres') - throw new Error(`Unsupported FROG_STORE \`${kind}\`. Use \`file\` or \`postgres\`.`) - if (!connectionString) throw new Error('The Postgres store requires DATABASE_URL.') + const connectionString = env['FROG_DATABASE_URL']?.trim() + if (!connectionString) return { kind: 'file' } const schema = env['FROG_SCHEMA']?.trim() return { connectionString, - kind, + kind: 'postgres', namespace: env['FROG_NAMESPACE']?.trim() || 'default', ...(schema ? { schema } : {}), } diff --git a/test/postgres.ts b/test/postgres.ts index 7402dee..1a17a35 100644 --- a/test/postgres.ts +++ b/test/postgres.ts @@ -9,7 +9,7 @@ export class FakePostgresClient implements PostgresStore.Client { async query = Record>( text: string, - values: readonly unknown[] = [], + values: unknown[] = [], ): Promise<{ rowCount: number; rows: T[] }> { this.queries.push(text) if (text.startsWith('CREATE ')) return { rowCount: 0, rows: [] } @@ -48,12 +48,15 @@ export class FakePostgresClient implements PostgresStore.Client { } } if (text.startsWith('INSERT INTO')) { - const [, rawId, rawDedupe, rawContents] = values + const [, rawId, rawDedupe, rawContents, rawTitleDedupe] = values const id = String(rawId) const previous = this.rows.get(key(id)) this.rows.set(key(id), { contents: String(rawContents), - dedupeKey: String(rawDedupe), + dedupeKey: + previous?.dedupeKey.startsWith('title:') && typeof rawTitleDedupe === 'string' + ? rawTitleDedupe + : (previous?.dedupeKey ?? String(rawDedupe)), id, occurrences: previous?.occurrences ?? 1, }) diff --git a/test/storeContract.ts b/test/storeContract.ts index ebc14db..60ff5b0 100644 --- a/test/storeContract.ts +++ b/test/storeContract.ts @@ -23,10 +23,11 @@ export function storeContract(name: string, create: () => Promise const written = await store.write(canonicalEntry) expect(written.id).toBeTruthy() + expect(written.location).toBeTruthy() expect(await store.get(written.id)).toEqual({ ...canonicalEntry, id: written.id }) expect(await store.read()).toEqual([{ ...canonicalEntry, id: written.id }]) expect(await store.list()).toEqual([written.id]) - expect(await store.files(written.id)).toEqual(expect.any(Array)) + if (store.files) expect(await store.files(written.id)).toEqual(expect.any(Array)) const updated = { ...canonicalEntry, From 2a6581df7eba8c510fde081fabddda2865135244 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:38:42 +1000 Subject: [PATCH 09/26] refactor: align pluggable stores --- .changeset/calm-frogs-store.md | 20 +- README.md | 20 +- src/Entry.test-d.ts | 14 ++ src/Entry.test.ts | 24 ++ src/Entry.ts | 36 ++- src/FrictionLog.test.ts | 52 ----- src/FrictionLog.ts | 68 ------ src/Frog.test-d.ts | 10 + src/Frog.test.ts | 57 +++++ src/Frog.ts | 62 ++++++ src/PostgresStore.test.ts | 102 --------- src/PostgresStore.ts | 192 ---------------- src/Store.postgres.test.ts | 120 ++++++++++ src/Store.test-d.ts | 8 + src/Store.test.ts | 62 ++++-- src/Store.ts | 369 +++++++++++++++++++++++++------ src/cli/Cli.ts | 105 +++++---- src/cli/commands/list.test.ts | 41 ++-- src/cli/commands/list.ts | 11 +- src/cli/commands/log.test.ts | 41 ++-- src/cli/commands/log.ts | 55 +++-- src/cli/commands/migrate.test.ts | 11 +- src/cli/commands/migrate.ts | 8 +- src/cli/commands/publish.test.ts | 14 +- src/cli/commands/publish.ts | 7 +- src/cli/commands/resolve.test.ts | 12 + src/cli/commands/resolve.ts | 10 +- src/cli/commands/sync.test.ts | 11 +- src/cli/commands/sync.ts | 15 +- src/cli/internal/store.ts | 14 +- src/index.ts | 8 +- test/cli.ts | 14 +- test/postgres.ts | 181 ++++++++------- test/storeContract.ts | 4 +- 34 files changed, 1038 insertions(+), 740 deletions(-) create mode 100644 src/Entry.test-d.ts delete mode 100644 src/FrictionLog.test.ts delete mode 100644 src/FrictionLog.ts create mode 100644 src/Frog.test-d.ts create mode 100644 src/Frog.test.ts create mode 100644 src/Frog.ts delete mode 100644 src/PostgresStore.test.ts delete mode 100644 src/PostgresStore.ts create mode 100644 src/Store.postgres.test.ts create mode 100644 src/Store.test-d.ts diff --git a/.changeset/calm-frogs-store.md b/.changeset/calm-frogs-store.md index ef8e78a..e683810 100644 --- a/.changeset/calm-frogs-store.md +++ b/.changeset/calm-frogs-store.md @@ -2,6 +2,20 @@ 'frog': minor --- -Add a public friction-store contract, a storage-independent `FrictionLog` API, and an optional -Postgres adapter while preserving the repository file store as the default. `FROG_DATABASE_URL` -automatically selects Postgres for CLI commands, and `frog migrate` prepares the selected store. +Added pluggable friction stores, a Postgres factory, and the `Frog.create` logging API. + +```ts +import { Frog, Store } from 'frog' +import { Pool } from 'pg' + +const client = new Pool({ connectionString: process.env.DATABASE_URL }) +const store = Store.postgres(client, { namespace: 'support-agent' }) +await store.migrate() +const frog = Frog.create({ store }) +await frog.log({ + body: 'The workaround used.', + severity: 'minor', + title: 'Tool required a workaround', +}) +const logs = await frog.logs() +``` diff --git a/README.md b/README.md index ccdf462..9285cc2 100644 --- a/README.md +++ b/README.md @@ -198,27 +198,27 @@ not change Frog's default store. Applications use the same store through the programmatic API: ```ts -import { FrictionLog, PostgresStore } from 'frog' +import { Frog, Store } from 'frog' import { Pool } from 'pg' -const pool = new Pool({ connectionString: process.env.DATABASE_URL }) -await PostgresStore.migrate({ client: pool }) -const store = PostgresStore.adapter({ client: pool, namespace: 'support-agent' }) -const frog = new FrictionLog({ store }) +const client = new Pool({ connectionString: process.env.DATABASE_URL }) +const store = Store.postgres(client, { namespace: 'support-agent' }) +await store.migrate() +const frog = Frog.create({ store }) -const result = await frog.record({ +const result = await frog.log({ title: 'Search result omitted its freshness', body: 'The caller could not tell when the result was collected.', severity: 'major', context: { source: 'production-agent', execution: 'opaque-reference' }, }) -const unresolved = await frog.records() // canonical entries with deduplicated occurrence counts +const unresolved = await frog.logs() // canonical entries with deduplicated occurrence counts ``` -Every store implements the exported `FrictionStore` contract and preserves the same `Entry` fields. -Storage metadata such as occurrence counts stays outside that entry schema. Custom adapters can use a -remote service, SQLite, or another database. Repository and GitHub automation—artifacts, `list --since`, +Every store implements `Store.Store` and preserves the same `Entry` fields. Use `Store.from` to adapt a +remote service, SQLite database, or another backend. Storage metadata such as occurrence counts stays +outside the entry schema. Repository and GitHub automation—artifacts, `list --since`, `log --open`, `log --publish`, `publish`, and `sync`—remains available only with the file store. ### Logging Upstream diff --git a/src/Entry.test-d.ts b/src/Entry.test-d.ts new file mode 100644 index 0000000..cead77e --- /dev/null +++ b/src/Entry.test-d.ts @@ -0,0 +1,14 @@ +import * as Entry from './Entry.js' + +const context = { + attempts: 2, + flags: [true, false, null], + source: { kind: 'agent' }, +} as const satisfies Entry.Context + +expectTypeOf(context).toMatchTypeOf() + +// @ts-expect-error Dates do not preserve their value through entry serialization. +const invalidContext: Entry.Context = { collectedAt: new Date() } + +expectTypeOf(invalidContext).toEqualTypeOf() diff --git a/src/Entry.test.ts b/src/Entry.test.ts index b3768f0..6d0d87e 100644 --- a/src/Entry.test.ts +++ b/src/Entry.test.ts @@ -139,6 +139,20 @@ describe('serialize', () => { Entry.serialize({ body: 'Body.', labels: [], severity: 'minor', title: 'Slow' }), ).not.toContain('labels') }) + + test.each([new Date(), new Map(), undefined, 1n])( + 'error: rejects context values that cannot round trip: %o', + (value) => { + expect(() => + Entry.serialize({ + body: 'Body.', + context: { value } as unknown as Entry.Context, + severity: 'minor', + title: 'Slow', + }), + ).toThrow() + }, + ) }) describe('round trip', () => { @@ -150,6 +164,16 @@ describe('round trip', () => { { body: 'Body.', severity: 'minor', title: '@scope/pkg: 100% broken #1 @ 3:00' }, { body: 'Body.', severity: 'minor', title: 'no: yes, true, null, ~, 0x1' }, { body: 'Body.', severity: 'minor', title: 'emoji 🎉 and — dashes' }, + { + body: 'Body.', + context: { + attempts: 2, + flags: [true, false, null], + source: { kind: 'agent', version: '1' }, + }, + severity: 'minor', + title: 'structured context', + }, { body: '## Description\n\nMulti\n\nline\n\n```ts\nconst a = 1\n```', issue: 'wevm/viem#4821', diff --git a/src/Entry.ts b/src/Entry.ts index 040e1d9..4dd3ce9 100644 --- a/src/Entry.ts +++ b/src/Entry.ts @@ -14,10 +14,37 @@ export type Severity = (typeof severities)[number] /** Schema for {@link Severity}. */ export const Severity = z.enum(severities) +/** Recursive value that Frog can serialize and restore without changing its meaning. */ +export type ContextValue = + | boolean + | null + | number + | string + | readonly ContextValue[] + | { readonly [key: string]: ContextValue } + +/** Schema for {@link ContextValue}. */ +export const ContextValue: z.ZodType = z.lazy(() => + z.union([ + z.boolean(), + z.null(), + z.number().finite(), + z.string(), + z.array(ContextValue), + z.record(z.string(), ContextValue), + ]), +) + +/** Consumer-defined structured context stored without interpretation. */ +export type Context = Readonly> + +/** Schema for {@link Context}. */ +export const Context: z.ZodType = z.record(z.string(), ContextValue) + /** Frontmatter of an entry's write-up. */ export type Frontmatter = { /** Consumer-defined structured context. Frog stores it but does not interpret it. */ - context?: Readonly> | undefined + context?: Context | undefined /** Linked issue as `owner/name#number`. Written by publishing, absent while pending. */ issue?: string | undefined /** Extra issue labels, applied on top of the configured and severity labels. */ @@ -41,7 +68,7 @@ export type Frontmatter = { * annotation stops the hand-written type and the schema drifting. */ export const Frontmatter: z.ZodType = z.object({ - context: z.record(z.string(), z.unknown()).optional(), + context: Context.optional(), issue: z .string() .regex(/^[\w.-]+\/[\w.-]+#\d+$/) @@ -125,11 +152,14 @@ export declare namespace parse { */ export function serialize(entry: serialize.Options): string { const { body, context, issue, labels, severity, target, title } = entry + const normalizedContext = context === undefined ? undefined : Context.parse(context) const frontmatter = YAML.stringify( { title, severity, - ...(context && Object.keys(context).length ? { context } : {}), + ...(normalizedContext && Object.keys(normalizedContext).length + ? { context: normalizedContext } + : {}), ...(target ? { target } : {}), ...(labels?.length ? { labels } : {}), ...(issue ? { issue } : {}), diff --git a/src/FrictionLog.test.ts b/src/FrictionLog.test.ts deleted file mode 100644 index c3892e6..0000000 --- a/src/FrictionLog.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { tmpdir } from '../test/helpers.js' -import { FrictionLog } from './FrictionLog.js' - -const entry = { - body: 'It took an unnecessary workaround.', - severity: 'minor', - title: 'Filters ignored', -} as const - -describe('FrictionLog', () => { - test('behavior: defaults to the existing repository-file store', async () => { - const log = new FrictionLog({ root: await tmpdir() }) - const result = await log.record(entry) - - expect(result).toMatchObject({ created: true, occurrences: 1 }) - expect(log.store.name).toBe('file') - expect(await log.list()).toEqual([result.entry]) - expect(await log.records()).toEqual([{ entry: result.entry, occurrences: 1 }]) - }) - - test('behavior: deduplicates normalized titles without changing the file-store default', async () => { - const log = new FrictionLog({ root: await tmpdir() }) - const first = await log.record(entry) - const repeated = await log.record({ ...entry, title: 'filters: ignored!' }) - - expect(repeated).toEqual({ created: false, entry: first.entry, occurrences: 1 }) - expect(await log.list()).toHaveLength(1) - }) - - test('behavior: delegates atomic recording to an adapter that provides it', async () => { - const record = vi.fn(async () => ({ - created: false, - entry: { ...entry, id: 'existing' }, - occurrences: 4, - })) - const log = new FrictionLog({ - store: { - name: 'custom', - record, - read: vi.fn(), - list: vi.fn(), - get: vi.fn(), - write: vi.fn(), - remove: vi.fn(), - files: vi.fn(), - }, - }) - - await expect(log.record(entry)).resolves.toMatchObject({ created: false, occurrences: 4 }) - expect(record).toHaveBeenCalledWith(entry, {}) - }) -}) diff --git a/src/FrictionLog.ts b/src/FrictionLog.ts deleted file mode 100644 index bfc4d33..0000000 --- a/src/FrictionLog.ts +++ /dev/null @@ -1,68 +0,0 @@ -import * as Entry from './Entry.js' -import * as Store from './Store.js' - -/** One canonical entry and the number of times it has been observed. */ -export type StoredEntry = Store.StoredEntry - -/** Result of recording one friction occurrence. */ -export type RecordResult = StoredEntry & { - /** Whether this call created a new entry. */ - created: boolean -} - -export type Adapter = Store.Adapter & { - /** Optional atomic deduplication supplied by durable adapters. */ - record?( - entry: Entry.serialize.Options, - options?: { force?: boolean | undefined }, - ): Promise -} - -/** A storage-independent friction log for applications, CLIs, and agents. */ -export class FrictionLog { - readonly store: Adapter - - constructor(options: { root?: string | undefined; store?: Adapter | undefined } = {}) { - this.store = options.store ?? Store.adapter({ root: options.root ?? process.cwd() }) - } - - list(): Promise { - return this.store.read() - } - - /** Lists canonical entries with occurrence counts when the store tracks them. */ - async records(): Promise { - if (this.store.records) return this.store.records() - return (await this.store.read()).map((entry) => ({ entry, occurrences: 1 })) - } - - get(id: string): Promise { - return this.store.get(id) - } - - async record( - entry: Entry.serialize.Options, - options: { force?: boolean | undefined } = {}, - ): Promise { - if (this.store.record) return this.store.record(entry, options) - - if (!options.force) { - const duplicate = (await this.store.read()).find( - (candidate) => Entry.normalizeTitle(candidate.title) === Entry.normalizeTitle(entry.title), - ) - if (duplicate) return { created: false, entry: duplicate, occurrences: 1 } - } - - const written = await this.store.write(entry) - return { created: true, entry: await this.store.get(written.id), occurrences: 1 } - } - - async update(id: string, entry: Entry.serialize.Options): Promise { - await this.store.write(entry, { id }) - return this.store.get(id) - } - - remove(id: string): Promise { - return this.store.remove(id) - } -} diff --git a/src/Frog.test-d.ts b/src/Frog.test-d.ts new file mode 100644 index 0000000..f5ffea8 --- /dev/null +++ b/src/Frog.test-d.ts @@ -0,0 +1,10 @@ +import * as Frog from './Frog.js' +import * as Store from './Store.js' + +declare const store: Store.Store + +const frog = Frog.create({ store }) + +expectTypeOf(frog).toEqualTypeOf() +expectTypeOf(frog.log).returns.resolves.toEqualTypeOf() +expectTypeOf(frog.logs).returns.resolves.toEqualTypeOf() diff --git a/src/Frog.test.ts b/src/Frog.test.ts new file mode 100644 index 0000000..8f7f3fa --- /dev/null +++ b/src/Frog.test.ts @@ -0,0 +1,57 @@ +import { tmpdir } from '../test/helpers.js' +import * as Frog from './Frog.js' +import * as Store from './Store.js' + +const entry = { + body: 'It took an unnecessary workaround.', + severity: 'minor', + title: 'Filters ignored', +} as const + +describe('create', () => { + test('behavior: returns a plain logger around the supplied store', async () => { + const store = Store.file({ root: await tmpdir() }) + const frog = Frog.create({ store }) + const result = await frog.log(entry) + + expect(Object.getPrototypeOf(frog)).toBe(Object.prototype) + expect(result).toMatchObject({ created: true, occurrences: 1 }) + expect(frog.store).toBe(store) + expect(await frog.logs()).toEqual([{ entry: result.entry, occurrences: 1 }]) + }) + + test('behavior: deduplicates normalized titles for stores without atomic logging', async () => { + const frog = Frog.create({ store: Store.file({ root: await tmpdir() }) }) + const first = await frog.log(entry) + const repeated = await frog.log({ ...entry, title: 'filters: ignored!' }) + + expect(repeated).toEqual({ + created: false, + entry: first.entry, + location: first.location, + occurrences: 1, + }) + expect(await frog.logs()).toHaveLength(1) + }) + + test('behavior: delegates atomic logging to a store that provides it', async () => { + const log = vi.fn(async () => ({ + created: false, + entry: { ...entry, id: 'existing' }, + location: 'custom:existing', + occurrences: 4, + })) + const store = Store.from({ + name: 'custom', + log, + read: vi.fn(), + get: vi.fn(), + write: vi.fn(), + remove: vi.fn(), + }) + const frog = Frog.create({ store }) + + await expect(frog.log(entry)).resolves.toMatchObject({ created: false, occurrences: 4 }) + expect(log).toHaveBeenCalledWith(entry, {}) + }) +}) diff --git a/src/Frog.ts b/src/Frog.ts new file mode 100644 index 0000000..d8bd808 --- /dev/null +++ b/src/Frog.ts @@ -0,0 +1,62 @@ +import * as Entry from './Entry.js' +import * as Store from './Store.js' + +/** A storage-independent friction logger. */ +export type Frog = { + /** Store used for every operation. */ + readonly store: Store.Store + /** Logs one friction occurrence. */ + readonly log: ( + entry: Entry.serialize.Options, + options?: Store.LogOptions, + ) => Promise + /** Lists canonical friction entries with their occurrence counts. */ + readonly logs: () => Promise +} + +/** Creates a friction logger around one explicitly constructed store. */ +export function create(options: create.Options): Frog { + const store = options.store + return { + store, + log: (entry, logOptions = {}) => log(store, entry, logOptions), + logs: () => store.records(), + } +} + +export declare namespace create { + /** Options for {@link create}. */ + type Options = { + /** Store used for every operation. */ + store: Store.Store + } +} + +async function log( + store: Store.Store, + entry: Entry.serialize.Options, + options: Store.LogOptions, +): Promise { + if (store.log) return store.log(entry, options) + + if (!options.force) { + const duplicate = (await store.read()).find( + (candidate) => Entry.normalizeTitle(candidate.title) === Entry.normalizeTitle(entry.title), + ) + if (duplicate) + return { + created: false, + entry: duplicate, + location: store.location(duplicate.id), + occurrences: 1, + } + } + + const written = await store.write(entry) + return { + created: true, + entry: await store.get(written.id), + location: written.location, + occurrences: 1, + } +} diff --git a/src/PostgresStore.test.ts b/src/PostgresStore.test.ts deleted file mode 100644 index c5780ce..0000000 --- a/src/PostgresStore.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import * as Entry from './Entry.js' -import { FrictionLog } from './FrictionLog.js' -import * as PostgresStore from './PostgresStore.js' -import { FakePostgresClient } from '../test/postgres.js' -import { storeContract } from '../test/storeContract.js' - -const friction = { - body: 'The tool required an unnecessary workaround.', - context: { source: 'production-agent', trace: 'opaque-reference' }, - severity: 'major', - title: 'Tool result omitted its state', -} as const - -describe('PostgresStore', () => { - test('behavior: migration is explicit, namespaced, and idempotent SQL', async () => { - const client = new FakePostgresClient() - await PostgresStore.migrate({ client, schema: 'frog' }) - expect(client.queries).toHaveLength(2) - expect(client.queries[0]).toBe('CREATE SCHEMA IF NOT EXISTS "frog"') - expect(client.queries[1]).toContain('CREATE TABLE IF NOT EXISTS "frog"."frog_entries"') - expect(client.queries[1]).toContain('UNIQUE (namespace, dedupe_key)') - }) - - test('behavior: an omitted schema follows the client search path', async () => { - const client = new FakePostgresClient() - await PostgresStore.migrate({ client }) - expect(client.queries).toHaveLength(1) - expect(client.queries[0]).toContain('CREATE TABLE IF NOT EXISTS "frog_entries"') - - const log = new FrictionLog({ - store: PostgresStore.adapter({ client, namespace: 'consumer-a' }), - }) - await log.record(friction) - expect(client.queries.at(-1)).toContain('INSERT INTO "frog_entries"') - }) - - test('behavior: records, deduplicates, updates, lists, and removes through the public API', async () => { - const client = new FakePostgresClient() - const log = new FrictionLog({ - store: PostgresStore.adapter({ client, namespace: 'consumer-a' }), - }) - - const first = await log.record(friction) - const repeated = await log.record({ ...friction, body: 'A later occurrence.' }) - expect(first).toMatchObject({ created: true, occurrences: 1 }) - expect(repeated).toMatchObject({ created: false, occurrences: 2, entry: first.entry }) - expect(first.entry.id).toMatch(/^\d{14}-tool-result-omitted-[0-9a-f]{8}$/) - expect(await log.list()).toEqual([first.entry]) - expect(await log.records()).toEqual([{ entry: first.entry, occurrences: 2 }]) - - const updated = await log.update(first.entry.id, { ...friction, issue: 'wevm/frog#123' }) - expect(updated.issue).toBe('wevm/frog#123') - await expect(log.remove(first.entry.id)).resolves.toBe(true) - await expect(log.remove(first.entry.id)).resolves.toBe(false) - await expect(log.get(first.entry.id)).rejects.toBeInstanceOf(PostgresStore.NotFoundError) - }) - - test('behavior: updating a recorded title moves its deduplication identity', async () => { - const client = new FakePostgresClient() - const log = new FrictionLog({ - store: PostgresStore.adapter({ client, namespace: 'consumer-a' }), - }) - const first = await log.record(friction) - - await log.update(first.entry.id, { ...friction, title: 'Tool state was omitted' }) - const repeated = await log.record({ ...friction, title: 'tool state was omitted!' }) - - expect(repeated).toMatchObject({ created: false, occurrences: 2 }) - expect(repeated.entry.id).toBe(first.entry.id) - }) - - test('behavior: namespaces isolate consumers and force preserves intentional duplicates', async () => { - const client = new FakePostgresClient() - const first = new FrictionLog({ store: PostgresStore.adapter({ client, namespace: 'one' }) }) - const second = new FrictionLog({ store: PostgresStore.adapter({ client, namespace: 'two' }) }) - - await first.record(friction) - await first.record(friction, { force: true }) - await second.record(friction) - expect(await first.list()).toHaveLength(2) - expect(await second.list()).toHaveLength(1) - }) - - test('error: rejects unsafe schema names before issuing SQL', () => { - expect(() => - PostgresStore.adapter({ - client: new FakePostgresClient(), - namespace: 'one', - schema: 'public; DROP TABLE users', - }), - ).toThrow('Postgres schema must be a SQL identifier.') - }) - - test('behavior: consumer context round trips without Frog interpreting it', () => { - const serialized = Entry.serialize(friction) - expect(Entry.parse(serialized, { id: 'one' }).context).toEqual(friction.context) - }) -}) - -storeContract('Postgres', async () => - PostgresStore.adapter({ client: new FakePostgresClient(), namespace: 'contract' }), -) diff --git a/src/PostgresStore.ts b/src/PostgresStore.ts deleted file mode 100644 index 8016a4f..0000000 --- a/src/PostgresStore.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { randomUUID } from 'node:crypto' -import * as Entry from './Entry.js' -import type * as FrictionLog from './FrictionLog.js' - -/** Minimal structural client implemented by `pg` pools and transaction clients. */ -export type Client = { - query = Record>( - text: string, - values?: unknown[], - ): Promise<{ - /** Number of affected rows when the driver supplies it. */ - rowCount?: number | null | undefined - /** Query result rows. */ - rows: T[] - }> -} - -/** Postgres schema lifecycle configuration. */ -export type MigrationOptions = { - /** Pool or transaction client used for every query. */ - client: Client - /** Optional PostgreSQL schema. Omit it to use the client's current search path. */ - schema?: string | undefined -} - -/** Postgres adapter configuration. */ -export type Options = MigrationOptions & { - /** Isolates independent consumers sharing one table. */ - namespace: string -} - -type Row = { - contents: string - created?: boolean | undefined - id: string - occurrence_count: number | string -} - -/** Creates the tables required by the Postgres adapter. Safe to call repeatedly. */ -export async function migrate(options: MigrationOptions): Promise { - const schema = options.schema === undefined ? undefined : schemaName(options.schema) - const table = tableName(schema) - if (schema !== undefined) await options.client.query(`CREATE SCHEMA IF NOT EXISTS "${schema}"`) - await options.client.query( - `CREATE TABLE IF NOT EXISTS ${table} ( - namespace text NOT NULL, - id text NOT NULL, - dedupe_key text NOT NULL, - contents text NOT NULL, - occurrence_count integer NOT NULL DEFAULT 1 CHECK (occurrence_count > 0), - created_at timestamptz NOT NULL DEFAULT now(), - updated_at timestamptz NOT NULL DEFAULT now(), - PRIMARY KEY (namespace, id), - UNIQUE (namespace, dedupe_key) - )`, - ) -} - -/** Postgres-backed Frog store. Schema creation is explicit through {@link migrate}. */ -export function adapter(options: Options): FrictionLog.Adapter { - const namespace = required(options.namespace, 'namespace') - const table = tableName(options.schema) - const client = options.client - - const get = async (id: string): Promise => { - const result = await client.query( - `SELECT id, contents, occurrence_count FROM ${table} WHERE namespace = $1 AND id = $2`, - [namespace, id], - ) - const row = result.rows[0] - if (!row) throw new NotFoundError(id) - return Entry.parse(row.contents, { id: row.id }) - } - - const store: FrictionLog.Adapter = { - name: 'postgres', - migrate: () => migrate(options), - async read() { - const result = await client.query( - `SELECT id, contents, occurrence_count FROM ${table} WHERE namespace = $1 ORDER BY id`, - [namespace], - ) - return result.rows.map((row) => Entry.parse(row.contents, { id: row.id })) - }, - async list() { - const result = await client.query<{ id: string }>( - `SELECT id FROM ${table} WHERE namespace = $1 ORDER BY id`, - [namespace], - ) - return result.rows.map((row) => row.id) - }, - async records() { - const result = await client.query( - `SELECT id, contents, occurrence_count FROM ${table} WHERE namespace = $1 ORDER BY id`, - [namespace], - ) - return result.rows.map((row) => ({ - entry: Entry.parse(row.contents, { id: row.id }), - occurrences: Number(row.occurrence_count), - })) - }, - get, - async write(entry, writeOptions = {}) { - const id = writeOptions.id ?? newId(entry.title) - const contents = Entry.serialize(entry) - const dedupeKey = `entry:${id}` - const titleKey = `title:${Entry.normalizeTitle(entry.title)}` - await client.query( - `INSERT INTO ${table}(namespace, id, dedupe_key, contents) - VALUES ($1, $2, $3, $4) - ON CONFLICT(namespace, id) DO UPDATE SET - dedupe_key = CASE - WHEN ${table}.dedupe_key LIKE 'title:%' THEN $5 - ELSE ${table}.dedupe_key - END, - contents = EXCLUDED.contents, - updated_at = now()`, - [namespace, id, dedupeKey, contents, titleKey], - ) - return { id, location: location(namespace, id) } - }, - async remove(id) { - const result = await client.query(`DELETE FROM ${table} WHERE namespace = $1 AND id = $2`, [ - namespace, - id, - ]) - return (result.rowCount ?? 0) > 0 - }, - async files() { - return [] - }, - async record(entry, recordOptions = {}) { - const id = newId(entry.title) - const dedupeKey = recordOptions.force - ? `forced:${id}` - : `title:${Entry.normalizeTitle(entry.title)}` - const result = await client.query( - `INSERT INTO ${table}(namespace, id, dedupe_key, contents) - VALUES ($1, $2, $3, $4) - ON CONFLICT(namespace, dedupe_key) DO UPDATE SET - occurrence_count = ${table}.occurrence_count + 1, - updated_at = now() - RETURNING id, contents, occurrence_count, (occurrence_count = 1) AS created`, - [namespace, id, dedupeKey, Entry.serialize(entry)], - ) - const row = result.rows[0] - if (!row) throw new Error('Postgres did not return the recorded friction entry.') - return { - created: row.created === true, - entry: Entry.parse(row.contents, { id: row.id }), - occurrences: Number(row.occurrence_count), - } - }, - } - return store -} - -function newId(title: string): string { - return `${Entry.newId({ title })}-${randomUUID().slice(0, 8)}` -} - -function schemaName(schema: string): string { - if (!/^[a-z_][a-z0-9_]*$/i.test(schema)) - throw new Error('Postgres schema must be a SQL identifier.') - return schema -} - -function tableName(schema?: string): string { - return schema === undefined ? '"frog_entries"' : `"${schemaName(schema)}"."frog_entries"` -} - -function required(value: string, name: string): string { - const normalized = value.trim() - if (!normalized) throw new Error(`Postgres ${name} is required.`) - return normalized -} - -function location(namespace: string, id: string): string { - return `postgres:${encodeURIComponent(namespace)}/${encodeURIComponent(id)}` -} - -/** Raised when a requested Postgres-backed entry does not exist. */ -export class NotFoundError extends Error { - /** Namespaced class name. */ - override name = 'PostgresStore.NotFoundError' - /** Machine-readable error code. */ - code = 'ENTRY_NOT_FOUND' as const - - constructor(id: string) { - super(`Friction entry \`${id}\` does not exist.`) - } -} diff --git a/src/Store.postgres.test.ts b/src/Store.postgres.test.ts new file mode 100644 index 0000000..c7cdce7 --- /dev/null +++ b/src/Store.postgres.test.ts @@ -0,0 +1,120 @@ +import { fakePostgresClient } from '../test/postgres.js' +import { storeContract } from '../test/storeContract.js' +import * as Entry from './Entry.js' +import * as Frog from './Frog.js' +import * as Store from './Store.js' + +const friction = { + body: 'The tool required an unnecessary workaround.', + context: { source: 'production-agent', trace: 'opaque-reference' }, + severity: 'major', + title: 'Tool result omitted its state', +} as const + +describe('postgres', () => { + test('behavior: migration is explicit, namespaced, and idempotent SQL', async () => { + const client = fakePostgresClient() + const store = Store.postgres(client, { namespace: 'consumer-a', schema: 'frog' }) + + await store.migrate() + + expect(client.queries).toHaveLength(2) + expect(client.queries[0]).toBe('CREATE SCHEMA IF NOT EXISTS "frog"') + expect(client.queries[1]).toContain('CREATE TABLE IF NOT EXISTS "frog"."frog_entries"') + expect(client.queries[1]).toContain('UNIQUE (namespace, dedupe_key)') + }) + + test('behavior: an omitted schema follows the client search path', async () => { + const client = fakePostgresClient() + const store = Store.postgres(client, { namespace: 'consumer-a' }) + const frog = Frog.create({ store }) + + await store.migrate() + await frog.log(friction) + + expect(client.queries).toHaveLength(2) + expect(client.queries[0]).toContain('CREATE TABLE IF NOT EXISTS "frog_entries"') + expect(client.queries[1]).toContain('INSERT INTO "frog_entries"') + }) + + test('behavior: logs, deduplicates, updates, lists, and removes', async () => { + const store = Store.postgres(fakePostgresClient(), { namespace: 'consumer-a' }) + const frog = Frog.create({ store }) + + const first = await frog.log(friction) + const repeated = await frog.log({ ...friction, body: 'A later occurrence.' }) + expect(first).toMatchObject({ created: true, occurrences: 1 }) + expect(repeated).toMatchObject({ created: false, occurrences: 2, entry: first.entry }) + expect(first.entry.id).toMatch(/^\d{14}-tool-result-omitted-[0-9a-f]{8}$/) + expect(await frog.logs()).toEqual([{ entry: first.entry, occurrences: 2 }]) + + await store.write({ ...friction, issue: 'wevm/frog#123' }, { id: first.entry.id }) + expect((await store.get(first.entry.id)).issue).toBe('wevm/frog#123') + await expect(store.remove(first.entry.id)).resolves.toBe(true) + await expect(store.remove(first.entry.id)).resolves.toBe(false) + await expect(store.get(first.entry.id)).rejects.toMatchObject({ + code: 'ENTRY_NOT_FOUND', + name: 'Store.NotFoundError', + }) + }) + + test('behavior: updating a logged title moves its deduplication identity', async () => { + const store = Store.postgres(fakePostgresClient(), { namespace: 'consumer-a' }) + const frog = Frog.create({ store }) + const first = await frog.log(friction) + + await store.write({ ...friction, title: 'Tool state was omitted' }, { id: first.entry.id }) + const repeated = await frog.log({ ...friction, title: 'tool state was omitted!' }) + + expect(repeated).toMatchObject({ created: false, occurrences: 2 }) + expect(repeated.entry.id).toBe(first.entry.id) + }) + + test('behavior: namespaces isolate consumers and force preserves intentional duplicates', async () => { + const client = fakePostgresClient() + const first = Frog.create({ store: Store.postgres(client, { namespace: 'one' }) }) + const second = Frog.create({ store: Store.postgres(client, { namespace: 'two' }) }) + + await first.log(friction) + await first.log(friction, { force: true }) + await second.log(friction) + expect(await first.logs()).toHaveLength(2) + expect(await second.logs()).toHaveLength(1) + }) + + test('behavior: removal uses returned rows when the client omits rowCount', async () => { + const backing = fakePostgresClient() + const client: Store.postgres.Client = { + async query = Record>( + text: string, + values?: unknown[], + ): Promise<{ rows: T[] }> { + const result = await backing.query(text, values) + return { rows: result.rows } + }, + } + const store = Store.postgres(client, { namespace: 'consumer-a' }) + const written = await store.write(friction) + + await expect(store.remove(written.id)).resolves.toBe(true) + await expect(store.remove(written.id)).resolves.toBe(false) + }) + + test('error: rejects unsafe schema names before issuing SQL', () => { + expect(() => + Store.postgres(fakePostgresClient(), { + namespace: 'one', + schema: 'public; DROP TABLE users', + }), + ).toThrow('Postgres schema must be a SQL identifier.') + }) + + test('behavior: consumer context round trips without Frog interpreting it', () => { + const serialized = Entry.serialize(friction) + expect(Entry.parse(serialized, { id: 'one' }).context).toEqual(friction.context) + }) +}) + +storeContract('Postgres', async () => + Store.postgres(fakePostgresClient(), { namespace: 'contract' }), +) diff --git a/src/Store.test-d.ts b/src/Store.test-d.ts new file mode 100644 index 0000000..c4becbb --- /dev/null +++ b/src/Store.test-d.ts @@ -0,0 +1,8 @@ +import * as Store from './Store.js' + +declare const client: Store.postgres.Client +declare const value: Store.from.Value + +expectTypeOf(Store.from(value)).toEqualTypeOf() +expectTypeOf(Store.file({ root: '/repo' })).toEqualTypeOf() +expectTypeOf(Store.postgres(client, { namespace: 'agent' })).toEqualTypeOf() diff --git a/src/Store.test.ts b/src/Store.test.ts index 473caad..9fa688b 100644 --- a/src/Store.test.ts +++ b/src/Store.test.ts @@ -7,19 +7,18 @@ import * as Store from './Store.js' const entry = "---\ntitle: 'Filters are ignored'\n---\n\nBody.\n" -storeContract('file', async () => Store.adapter({ root: await tmpdir() })) +storeContract('file', async () => Store.file({ root: await tmpdir() })) /** Writes an entry's write-up, creating its directory. */ function write(id: string, root: string, contents = entry) { return writeFile(Store.toPath(id), contents, root) } -test('behavior: an async adapter scope redirects every store operation', async () => { +test('behavior: from derives optional operations for a custom store', async () => { const entries = new Map() - const scoped: Store.Adapter = { + const store = Store.from({ name: 'memory', read: async () => [...entries.values()], - list: async () => [...entries.keys()], get: async (id) => entries.get(id)!, write: async (value, options = {}) => { const id = options.id ?? 'memory-id' @@ -27,23 +26,22 @@ test('behavior: an async adapter scope redirects every store operation', async ( return { id, location: `memory:${id}` } }, remove: async (id) => entries.delete(id), - files: async () => [], - } - const root = await tmpdir() + }) const value = { body: 'Body.', severity: 'minor', title: 'Scoped' } as const - await Store.withAdapter(scoped, async () => { - expect(Store.activeName()).toBe('memory') - await expect(Store.write(value, { root })).resolves.toEqual({ - file: 'memory:memory-id', - id: 'memory-id', - }) - await expect(Store.list({ root })).resolves.toEqual(['memory-id']) - await expect(Store.get('memory-id', { root })).resolves.toEqual({ ...value, id: 'memory-id' }) - await expect(Store.files('memory-id', { root })).resolves.toEqual([]) - await expect(Store.remove('memory-id', { root })).resolves.toBe(true) - }) - expect(Store.activeName()).toBe('file') + expect(store.tracksOccurrences).toBe(false) + expect(store.location('memory-id')).toBe('memory-id') + await expect(store.migrate()).resolves.toBeUndefined() + await expect(store.write(value)).resolves.toEqual({ + id: 'memory-id', + location: 'memory:memory-id', + }) + await expect(store.list()).resolves.toEqual(['memory-id']) + await expect(store.records()).resolves.toEqual([ + { entry: { ...value, id: 'memory-id' }, occurrences: 1 }, + ]) + await expect(store.files('memory-id')).resolves.toEqual([]) + await expect(store.remove('memory-id')).resolves.toBe(true) }) describe('write', () => { @@ -129,7 +127,8 @@ describe('write', () => { describe('list', () => { test('behavior: returns sorted ids and skips non-entries', async () => { const root = await tmpdir() - for (const id of ['apple', 'zebra', 'middle', '.hidden']) await write(id, root) + for (const id of ['apple', 'zebra', 'middle']) await write(id, root) + await writeFile(`${Store.dir}/.hidden/${Store.filename}`, entry, root) for (const name of ['README.md', 'TEMPLATE.md', 'config.json', 'notes.txt']) await writeFile(`${Store.dir}/${name}`, entry, root) @@ -215,6 +214,29 @@ describe('toDir', () => { test('behavior: builds a repo-relative directory', () => { expect(Store.toDir('lazy-squids-chew')).toBe('.agents/friction-log/lazy-squids-chew') }) + + test('error: rejects path traversal', () => { + expect(() => Store.toDir('..')).toThrowErrorMatchingInlineSnapshot( + `[Store.InvalidIdError: Friction entry id \`..\` is not path-safe.]`, + ) + expect(() => Store.toDir('../outside')).toThrow() + expect(() => Store.toDir('nested\\outside')).toThrow() + }) +}) + +describe('isId', () => { + test.each([ + ['entry', true], + ['entry-2', true], + ['', false], + ['.hidden', false], + ['..', false], + ['../outside', false], + ['nested/outside', false], + ['nested\\outside', false], + ])('behavior: %s', (id, expected) => { + expect(Store.isId(id)).toBe(expected) + }) }) describe('toPath', () => { diff --git a/src/Store.ts b/src/Store.ts index 7252112..7403378 100644 --- a/src/Store.ts +++ b/src/Store.ts @@ -1,10 +1,8 @@ +import { randomUUID } from 'node:crypto' import fs from 'node:fs/promises' -import { AsyncLocalStorage } from 'node:async_hooks' import path from 'node:path' import * as Entry from './Entry.js' -const activeAdapter = new AsyncLocalStorage() - /** Directory holding entries, relative to the repository root. */ export const dir = '.agents/friction-log' @@ -20,17 +18,17 @@ export type Options = { root: string } -/** Options for an adapter write. */ -export type AdapterWriteOptions = { +/** Options for writing through a store. */ +export type WriteOptions = { /** Existing entry id to replace. */ id?: string | undefined } -/** Result of writing through a storage adapter. */ -export type AdapterWriteResult = { +/** Result of writing through a store. */ +export type WriteResult = { /** Stable entry id. */ id: string - /** Adapter-defined location suitable for diagnostics. */ + /** Store-defined location suitable for diagnostics. */ location: string } @@ -38,68 +36,142 @@ export type AdapterWriteResult = { export type StoredEntry = { /** Canonical entry payload shared by every store. */ entry: Entry.Entry - /** Number of observations when the adapter tracks recurrence. */ + /** Number of observations when the store tracks recurrence. */ occurrences: number } -/** Storage operations consumed by Frog's programmatic API. */ -export type Adapter = { - /** Stable adapter name for diagnostics. */ +/** Result of logging one friction occurrence. */ +export type LogResult = StoredEntry & { + /** Whether this call created a new entry. */ + created: boolean + /** Store-defined location suitable for diagnostics. */ + location: string +} + +/** Storage operations consumed by Frog. */ +export type Store = { + /** Stable store name for diagnostics and capability checks. */ readonly name: string - /** Prepares adapter-owned storage, when required. Safe to call repeatedly. */ - migrate?(): Promise + /** Whether the store preserves occurrence counts beyond the canonical entry. */ + readonly tracksOccurrences: boolean + /** Prepares store-owned storage. Safe to call repeatedly. */ + readonly migrate: () => Promise /** Lists every entry in stable id order. */ - read(): Promise - /** Lists entries with recurrence metadata, when tracked by the adapter. */ - records?(): Promise + readonly read: () => Promise + /** Lists entries with recurrence metadata. */ + readonly records: () => Promise /** Lists entry ids in stable order. */ - list(): Promise + readonly list: () => Promise /** Reads one entry. */ - get(id: string): Promise + readonly get: (id: string) => Promise /** Writes an entry, optionally replacing a known id. Every canonical entry field must round trip. */ - write(entry: Entry.serialize.Options, options?: AdapterWriteOptions): Promise + readonly write: (entry: Entry.serialize.Options, options?: WriteOptions) => Promise /** Removes an entry and reports whether it existed. */ - remove(id: string): Promise - /** Lists adapter-owned artifact locations, when the adapter supports artifacts. */ - files?(id: string): Promise + readonly remove: (id: string) => Promise + /** Lists store-owned artifact locations. */ + readonly files: (id: string) => Promise + /** Returns the store-defined location for one entry. */ + readonly location: (id: string) => string + /** Atomically logs and deduplicates an occurrence when the store supports it. */ + readonly log?: + | ((entry: Entry.serialize.Options, options?: LogOptions) => Promise) + | undefined } -/** Runs store operations in one async scope through the supplied adapter. */ -export function withAdapter(store: Adapter, operation: () => Promise): Promise { - return activeAdapter.run(store, operation) +/** Options for logging one friction occurrence. */ +export type LogOptions = { + /** Preserve an intentional duplicate instead of deduplicating it. */ + force?: boolean | undefined } -/** Name of the adapter selected for this async scope. Defaults to the repository file store. */ -export function activeName(): string { - return activeAdapter.getStore()?.name ?? 'file' +/** Normalizes a custom store by supplying safe defaults for optional capabilities. */ +export function from(value: from.Value): Store { + return { + name: value.name, + tracksOccurrences: value.tracksOccurrences ?? value.records !== undefined, + migrate: value.migrate ?? (async () => {}), + read: value.read, + records: + value.records ?? + (async () => (await value.read()).map((entry) => ({ entry, occurrences: 1 }))), + list: value.list ?? (async () => (await value.read()).map((entry) => entry.id).sort()), + get: value.get, + write: value.write, + remove: value.remove, + files: value.files ?? (async () => []), + location: value.location ?? ((id) => id), + ...(value.log ? { log: value.log } : {}), + } } -/** Migrates the active adapter, returning whether it owns a migration. The file store needs none. */ -export async function migrate(): Promise { - const store = activeAdapter.getStore() - if (!store?.migrate) return false - await store.migrate() - return true +export declare namespace from { + /** Minimum operations required to adapt a store to Frog. */ + type Value = { + /** Stable store name for diagnostics and capability checks. */ + readonly name: string + /** Whether the store preserves occurrence counts beyond the canonical entry. */ + readonly tracksOccurrences?: boolean | undefined + /** Lists every entry in stable id order. */ + readonly read: () => Promise + /** Reads one entry. */ + readonly get: (id: string) => Promise + /** Writes an entry, optionally replacing a known id. */ + readonly write: (entry: Entry.serialize.Options, options?: WriteOptions) => Promise + /** Removes an entry and reports whether it existed. */ + readonly remove: (id: string) => Promise + /** Prepares store-owned storage. Safe to call repeatedly. */ + readonly migrate?: (() => Promise) | undefined + /** Lists entries with recurrence metadata. Derived from `read` by default. */ + readonly records?: (() => Promise) | undefined + /** Lists entry ids in stable order. Derived from `read` by default. */ + readonly list?: (() => Promise) | undefined + /** Lists store-owned artifact locations. Empty by default. */ + readonly files?: ((id: string) => Promise) | undefined + /** Returns the store-defined location for one entry. Defaults to its id. */ + readonly location?: ((id: string) => string) | undefined + /** Atomically logs and deduplicates an occurrence when the store supports it. */ + readonly log?: + | ((entry: Entry.serialize.Options, options?: LogOptions) => Promise) + | undefined + } } -/** Binds the existing repository-file store to one root. */ -export function adapter(options: Options): Adapter { - return { +/** Binds the repository-file store to one root. */ +export function file(options: file.Options): Store { + return from({ name: 'file', - read: () => activeAdapter.run(undefined, () => read(options)), - list: () => activeAdapter.run(undefined, () => list(options)), - get: (id) => activeAdapter.run(undefined, () => get(id, options)), + read: () => read(options), + list: () => list(options), + get: (id) => get(id, options), write: async (entry, writeOptions = {}) => { - const written = await activeAdapter.run(undefined, () => - write(entry, { ...writeOptions, root: options.root }), - ) + const written = await write(entry, { ...writeOptions, root: options.root }) return { id: written.id, location: written.file } }, - remove: (id) => activeAdapter.run(undefined, () => remove(id, options)), - files: (id) => activeAdapter.run(undefined, () => files(id, options)), + remove: (id) => remove(id, options), + files: (id) => files(id, options), + location: toPath, + }) +} + +export declare namespace file { + /** Options for {@link file}. */ + type Options = { + /** Repository root. Entries live in `/.agents/friction-log`. */ + root: string } } +/** Whether a value is a safe, visible entry-directory name. */ +export function isId(id: string): boolean { + return ( + id.length > 0 && + !id.startsWith('.') && + !id.includes('/') && + !id.includes('\\') && + !id.includes('\0') + ) +} + /** * Directory holding an entry and anything needed to reproduce it. * @@ -110,6 +182,7 @@ export function adapter(options: Options): Adapter { * @returns The repository-relative directory. */ export function toDir(id: string): string { + if (!isId(id)) throw invalidIdError(id) return `${dir}/${id}` } @@ -144,7 +217,7 @@ export function toId(file: string): string | undefined { if (!file.startsWith(`${dir}/`) || !file.endsWith(`/${filename}`)) return undefined const id = file.slice(dir.length + 1, -(filename.length + 1)) - if (!id || id.includes('/') || id.startsWith('.')) return undefined + if (!isId(id)) return undefined return id } @@ -154,16 +227,12 @@ export function toId(file: string): string | undefined { * @returns Every entry. Throws on the first malformed write-up rather than skipping it. */ export async function read(options: Options): Promise { - const selected = activeAdapter.getStore() - if (selected) return selected.read() const ids = await list(options) return Promise.all(ids.map((id) => get(id, options))) } -/** Lists canonical entries with recurrence metadata when the active adapter tracks it. */ +/** Lists file-store entries with their single-observation metadata. */ export async function records(options: Options): Promise { - const selected = activeAdapter.getStore() - if (selected?.records) return selected.records() return (await read(options)).map((entry) => ({ entry, occurrences: 1 })) } @@ -175,8 +244,6 @@ export async function records(options: Options): Promise * @returns Entry ids. A missing directory yields an empty list. */ export async function list(options: Options): Promise { - const selected = activeAdapter.getStore() - if (selected) return selected.list() const found = await fs .readdir(path.join(options.root, dir), { withFileTypes: true }) .catch((error) => { @@ -206,8 +273,6 @@ export async function list(options: Options): Promise { * @param id - Entry id. */ export async function get(id: string, options: Options): Promise { - const selected = activeAdapter.getStore() - if (selected) return selected.get(id) const contents = await fs.readFile(path.join(options.root, toPath(id)), 'utf8') return Entry.parse(contents, { id }) } @@ -221,8 +286,6 @@ export async function get(id: string, options: Options): Promise { * @returns Paths, sorted. Empty when the entry does not exist. */ export async function files(id: string, options: Options): Promise { - const selected = activeAdapter.getStore() - if (selected) return selected.files?.(id) ?? [] const base = path.join(options.root, toDir(id)) const found = await fs .readdir(base, { recursive: true, withFileTypes: true }) @@ -249,11 +312,6 @@ export async function write( entry: Entry.serialize.Options, options: write.Options, ): Promise { - const selected = activeAdapter.getStore() - if (selected) { - const written = await selected.write(entry, options.id ? { id: options.id } : {}) - return { file: written.location, id: written.id } - } const id = options.id ?? (await claim(entry.title, options)) const file = toPath(id) await fs.mkdir(path.join(options.root, toDir(id)), { recursive: true }) @@ -308,8 +366,6 @@ export declare namespace write { * not an error, so reconciliation stays safe to re-run. */ export async function remove(id: string, options: Options): Promise { - const selected = activeAdapter.getStore() - if (selected) return selected.remove(id) const base = path.join(options.root, toDir(id)) try { await fs.stat(base) @@ -319,3 +375,186 @@ export async function remove(id: string, options: Options): Promise { await fs.rm(base, { force: true, recursive: true }) return true } + +type PostgresRow = { + contents: string + created?: boolean | undefined + id: string + occurrence_count: number | string +} + +/** Creates a Postgres-backed store from a `pg`-compatible client. */ +export function postgres(client: postgres.Client, options: postgres.Options): Store { + const namespace = required(options.namespace, 'namespace') + const table = tableName(options.schema) + + const get = async (id: string): Promise => { + const result = await client.query( + `SELECT id, contents, occurrence_count FROM ${table} WHERE namespace = $1 AND id = $2`, + [namespace, id], + ) + const row = result.rows[0] + if (!row) throw notFoundError(id) + return Entry.parse(row.contents, { id: row.id }) + } + + return from({ + name: 'postgres', + tracksOccurrences: true, + location: (id) => postgresLocation(namespace, id), + async migrate() { + if (options.schema !== undefined) + await client.query(`CREATE SCHEMA IF NOT EXISTS "${schemaName(options.schema)}"`) + await client.query( + `CREATE TABLE IF NOT EXISTS ${table} ( + namespace text NOT NULL, + id text NOT NULL, + dedupe_key text NOT NULL, + contents text NOT NULL, + occurrence_count integer NOT NULL DEFAULT 1 CHECK (occurrence_count > 0), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (namespace, id), + UNIQUE (namespace, dedupe_key) + )`, + ) + }, + async read() { + const result = await client.query( + `SELECT id, contents, occurrence_count FROM ${table} WHERE namespace = $1 ORDER BY id`, + [namespace], + ) + return result.rows.map((row) => Entry.parse(row.contents, { id: row.id })) + }, + async records() { + const result = await client.query( + `SELECT id, contents, occurrence_count FROM ${table} WHERE namespace = $1 ORDER BY id`, + [namespace], + ) + return result.rows.map((row) => ({ + entry: Entry.parse(row.contents, { id: row.id }), + occurrences: Number(row.occurrence_count), + })) + }, + async list() { + const result = await client.query<{ id: string }>( + `SELECT id FROM ${table} WHERE namespace = $1 ORDER BY id`, + [namespace], + ) + return result.rows.map((row) => row.id) + }, + get, + async write(entry, writeOptions = {}) { + const id = writeOptions.id ?? newPostgresId(entry.title) + const dedupeKey = `entry:${id}` + const titleKey = `title:${Entry.normalizeTitle(entry.title)}` + await client.query( + `INSERT INTO ${table}(namespace, id, dedupe_key, contents) + VALUES ($1, $2, $3, $4) + ON CONFLICT(namespace, id) DO UPDATE SET + dedupe_key = CASE + WHEN ${table}.dedupe_key LIKE 'title:%' THEN $5 + ELSE ${table}.dedupe_key + END, + contents = EXCLUDED.contents, + updated_at = now()`, + [namespace, id, dedupeKey, Entry.serialize(entry), titleKey], + ) + return { id, location: postgresLocation(namespace, id) } + }, + async remove(id) { + const result = await client.query<{ id: string }>( + `DELETE FROM ${table} WHERE namespace = $1 AND id = $2 RETURNING id`, + [namespace, id], + ) + return result.rows.length > 0 + }, + async log(entry, logOptions = {}) { + const id = newPostgresId(entry.title) + const dedupeKey = logOptions.force + ? `forced:${id}` + : `title:${Entry.normalizeTitle(entry.title)}` + const result = await client.query( + `INSERT INTO ${table}(namespace, id, dedupe_key, contents) + VALUES ($1, $2, $3, $4) + ON CONFLICT(namespace, dedupe_key) DO UPDATE SET + occurrence_count = ${table}.occurrence_count + 1, + updated_at = now() + RETURNING id, contents, occurrence_count, (occurrence_count = 1) AS created`, + [namespace, id, dedupeKey, Entry.serialize(entry)], + ) + const row = result.rows[0] + if (!row) throw new Error('Postgres did not return the logged friction entry.') + return { + created: row.created === true, + entry: Entry.parse(row.contents, { id: row.id }), + location: postgresLocation(namespace, row.id), + occurrences: Number(row.occurrence_count), + } + }, + }) +} + +export declare namespace postgres { + /** Minimal structural client implemented by `pg` pools and transaction clients. */ + type Client = { + /** Executes SQL with optional parameter values. */ + query = Record>( + text: string, + values?: unknown[], + ): Promise<{ + /** Number of affected rows when the driver supplies it. */ + rowCount?: number | null | undefined + /** Query result rows. */ + rows: T[] + }> + } + + /** Postgres store configuration. */ + type Options = { + /** Isolates independent consumers sharing one table. */ + namespace: string + /** Optional PostgreSQL schema. Omit it to use the client's current search path. */ + schema?: string | undefined + } +} + +function newPostgresId(title: string): string { + return `${Entry.newId({ title })}-${randomUUID().slice(0, 8)}` +} + +function schemaName(schema: string): string { + if (!/^[a-z_][a-z0-9_]*$/i.test(schema)) + throw new Error('Postgres schema must be a SQL identifier.') + return schema +} + +function tableName(schema?: string): string { + return schema === undefined ? '"frog_entries"' : `"${schemaName(schema)}"."frog_entries"` +} + +function required(value: string, name: string): string { + const normalized = value.trim() + if (!normalized) throw new Error(`Postgres ${name} is required.`) + return normalized +} + +function postgresLocation(namespace: string, id: string): string { + return `postgres:${encodeURIComponent(namespace)}/${encodeURIComponent(id)}` +} + +function notFoundError(id: string): Error & { code: 'ENTRY_NOT_FOUND' } { + const error = Object.assign(new Error(`Friction entry \`${id}\` does not exist.`), { + code: 'ENTRY_NOT_FOUND' as const, + }) + error.name = 'Store.NotFoundError' + return error +} + +function invalidIdError(id: string): Error & { code: 'INVALID_ENTRY_ID' } { + const error = Object.assign(new Error(`Friction entry id \`${id}\` is not path-safe.`), { + code: 'INVALID_ENTRY_ID' as const, + }) + error.name = 'Store.InvalidIdError' + return error +} diff --git a/src/cli/Cli.ts b/src/cli/Cli.ts index 8fc455e..e70bfc5 100644 --- a/src/cli/Cli.ts +++ b/src/cli/Cli.ts @@ -1,4 +1,4 @@ -import { Binary, Cli, z } from 'incur' +import { Binary, Cli, middleware, z } from 'incur' import { init } from './commands/init.js' import { list } from './commands/list.js' import { log } from './commands/log.js' @@ -10,7 +10,7 @@ import { targets } from './commands/targets.js' import * as context from './internal/context.js' import * as packageManager from './internal/packageManager.js' import * as environmentStore from './internal/store.js' -import * as Store from '../Store.js' +import type * as Store from '../Store.js' const globalOptionValues = new Set([ '--filter-output', @@ -21,58 +21,83 @@ const globalOptionValues = new Set([ const storedCommands = new Set(['list', 'log', 'migrate', 'publish', 'resolve', 'sync']) const introspectionOptions = new Set(['--help', '--llms', '--llms-full', '--schema', '--version']) - -export const cli = Cli.create('frog', { - description: 'Automated friction logging for agents.', - env: z.object({ - FROG_DATABASE_URL: z - .string() - .optional() - .describe('Postgres URL. Its presence selects the Postgres store.'), - FROG_NAMESPACE: z.string().optional().describe('Postgres namespace. Defaults to `default`.'), - FROG_SCHEMA: z.string().optional().describe('Optional Postgres schema.'), - }), - sync: { - depth: 1, - suggestions: [ - 'log the friction I just hit', - 'show me which of my dependencies accept friction reports', - ], - }, - update: Binary.github({ repository: 'wevm/frog' }), +const envSchema = z.object({ + FROG_DATABASE_URL: z + .string() + .optional() + .describe('Postgres URL. Its presence selects the Postgres store.'), + FROG_NAMESPACE: z.string().optional().describe('Postgres namespace. Defaults to `default`.'), + FROG_SCHEMA: z.string().optional().describe('Optional Postgres schema.'), }) - .command(init) - .command(list) - .command(log) - .command(migrate) - .command(publish) - .command(resolve) - .command(sync) - .command(targets) + +/** Creates the CLI with one optional store for commands that persist friction. */ +export function create(options: create.Options = {}) { + return Cli.create('frog', { + description: 'Automated friction logging for agents.', + env: envSchema, + vars: environmentStore.vars, + sync: { + depth: 1, + suggestions: [ + 'log the friction I just hit', + 'show me which of my dependencies accept friction reports', + ], + }, + update: Binary.github({ repository: 'wevm/frog' }), + }) + .use( + middleware(async (context, next) => { + if (options.store) context.set('store', options.store) + await next() + }), + ) + .command(init) + .command(list) + .command(log) + .command(migrate) + .command(publish) + .command(resolve) + .command(sync) + .command(targets) +} + +export declare namespace create { + type Options = { + /** Store injected into commands that persist friction. The file store is derived per command by default. */ + store?: Store.Store | undefined + } +} + +export const cli = create() /** Serves init with the project runner when one is detected. */ -export async function serve( - argv: string[] = process.argv.slice(2), - options: Cli.serve.Options = {}, -) { - const selected = usesStore(argv) - ? await environmentStore.resolve(options.env ?? process.env) - : undefined +export async function serve(argv: string[] = process.argv.slice(2), options: serve.Options = {}) { + const { store, ...serveOptions } = options + const selected = + store === undefined && usesStore(argv) + ? await environmentStore.resolve(options.env ?? process.env) + : undefined + const commandStore = store ?? selected?.store + const runnerCli = create(commandStore ? { store: commandStore } : {}) const run = async () => { - if (command(argv) !== 'init') return cli.serve(argv, options) + if (command(argv) !== 'init') return runnerCli.serve(argv, serveOptions) const { root } = await context.resolve({ cwd: option(argv, '--cwd') }) const runner = await packageManager.resolve({ env: options.env, root }) - if (!runner) return cli.serve(argv, options) - return Cli.create(runner).command(init).serve(argv, options) + if (!runner) return runnerCli.serve(argv, serveOptions) + return Cli.create(runner).command(init).serve(argv, serveOptions) } try { - return selected ? await Store.withAdapter(selected.adapter, run) : await run() + return await run() } finally { await selected?.close() } } +export declare namespace serve { + type Options = Cli.serve.Options & create.Options +} + export default cli function command(argv: readonly string[]): string | undefined { diff --git a/src/cli/commands/list.test.ts b/src/cli/commands/list.test.ts index ec115b3..a2b2da7 100644 --- a/src/cli/commands/list.test.ts +++ b/src/cli/commands/list.test.ts @@ -1,8 +1,7 @@ import * as cli from '../../../test/cli.js' import * as helpers from '../../../test/helpers.js' -import { FakePostgresClient } from '../../../test/postgres.js' -import { FrictionLog } from '../../FrictionLog.js' -import * as PostgresStore from '../../PostgresStore.js' +import { fakePostgresClient } from '../../../test/postgres.js' +import * as Frog from '../../Frog.js' import * as Store from '../../Store.js' const body = 'The filter was swallowed.' @@ -79,19 +78,33 @@ test('behavior: an empty directory lists nothing', async () => { }) test('behavior: a durable store lists occurrence counts', async () => { - const store = PostgresStore.adapter({ - client: new FakePostgresClient(), - namespace: 'list-test', + const store = Store.postgres(fakePostgresClient(), { namespace: 'list-test' }) + const frog = Frog.create({ store }) + await frog.log({ body, severity: 'minor', title: 'Repeated friction' }) + await frog.log({ body, severity: 'minor', title: 'repeated friction' }) + + expect(await cli.data(['list', '--cwd', await helpers.repo()], {}, { store })).toMatchObject({ + entries: [{ occurrences: 2, title: 'Repeated friction' }], }) - const log = new FrictionLog({ store }) - await log.record({ body, severity: 'minor', title: 'Repeated friction' }) - await log.record({ body, severity: 'minor', title: 'repeated friction' }) - - await Store.withAdapter(store, async () => { - expect(await cli.data(['list', '--cwd', await helpers.repo()])).toMatchObject({ - entries: [{ occurrences: 2, title: 'Repeated friction' }], - }) +}) + +test('behavior: a custom store without recurrence metadata omits occurrence counts', async () => { + const entry = { body, id: 'one', severity: 'minor', title: 'One occurrence' } as const + const store = Store.from({ + name: 'memory', + read: async () => [entry], + get: async () => entry, + write: async () => ({ id: entry.id, location: entry.id }), + remove: async () => false, }) + + const result = await cli.data<{ entries: { occurrences?: number | undefined }[] }>( + ['list', '--cwd', await helpers.repo()], + {}, + { store }, + ) + + expect(result.entries[0]).not.toHaveProperty('occurrences') }) test('behavior: filters by state', async () => { diff --git a/src/cli/commands/list.ts b/src/cli/commands/list.ts index bc63906..7701056 100644 --- a/src/cli/commands/list.ts +++ b/src/cli/commands/list.ts @@ -3,11 +3,13 @@ import * as Git from '../../Git.js' import * as Store from '../../Store.js' import { attempt } from '../internal/attempt.js' import * as context from '../internal/context.js' +import * as environmentStore from '../internal/store.js' /** Local state of an entry. Remote issue state arrives with `sync`. */ const State = z.enum(['linked', 'pending']) export const list = Cli.create('list', { + vars: environmentStore.vars, description: 'List entries with their state.', options: z.object({ cwd: context.cwdOption, @@ -46,15 +48,16 @@ export const list = Cli.create('list', { }), async run(c) { const { root } = await context.resolve({ cwd: c.options.cwd }) + const store = c.var.store ?? Store.file({ root }) - if (c.options.since && Store.activeName() !== 'file') + if (c.options.since && store.name !== 'file') return c.error({ code: 'STORE_UNSUPPORTED_OPTION', message: '`--since` is available only with the repository file store.', }) // Both `c.error` calls stay at the top level of `run`. See `internal/attempt.ts` for why. - const records = await attempt(Store.records({ root })) + const records = await attempt(store.records()) if (!records.ok) return c.error({ code: records.code, @@ -90,11 +93,11 @@ export const list = Cli.create('list', { const listed = await Promise.all( selected.map(async (entry) => { - const files = await Store.files(entry.id, { root }) + const files = await store.files(entry.id) const artifacts = files.filter((file) => file.startsWith(`${Store.toArtifacts(entry.id)}/`)) return { id: entry.id, - ...(Store.activeName() === 'file' ? {} : { occurrences: occurrences.get(entry.id) ?? 1 }), + ...(store.tracksOccurrences ? { occurrences: occurrences.get(entry.id) ?? 1 } : {}), severity: entry.severity, state: entry.issue ? ('linked' as const) : ('pending' as const), title: entry.title, diff --git a/src/cli/commands/log.test.ts b/src/cli/commands/log.test.ts index 77c8366..34f0205 100644 --- a/src/cli/commands/log.test.ts +++ b/src/cli/commands/log.test.ts @@ -5,36 +5,49 @@ import path from 'node:path' import * as cli from '../../../test/cli.js' import { github } from '../../../test/github.js' import * as helpers from '../../../test/helpers.js' -import { FakePostgresClient } from '../../../test/postgres.js' +import { fakePostgresClient } from '../../../test/postgres.js' import * as Config from '../../Config.js' -import * as PostgresStore from '../../PostgresStore.js' import * as Store from '../../Store.js' const title = '`pnpm test -- ` ignores file filters' const body = '## Description\n\nThe filter was swallowed.' test('error: immediate publishing requires the file store', async () => { - const store = PostgresStore.adapter({ client: new FakePostgresClient(), namespace: 'log-test' }) + const store = Store.postgres(fakePostgresClient(), { namespace: 'log-test' }) const cwd = await helpers.repo() - await Store.withAdapter(store, async () => { - expect((await cli.error(['log', title, '--body', body, '--publish', '--cwd', cwd])).code).toBe( - 'STORE_UNSUPPORTED_OPTION', - ) - }) + expect( + (await cli.error(['log', title, '--body', body, '--publish', '--cwd', cwd], {}, { store })) + .code, + ).toBe('STORE_UNSUPPORTED_OPTION') }) test('behavior: durable-store follow-up does not suggest repository publishing', async () => { - const store = PostgresStore.adapter({ client: new FakePostgresClient(), namespace: 'log-test' }) + const store = Store.postgres(fakePostgresClient(), { namespace: 'log-test' }) const cwd = await helpers.repo() - await Store.withAdapter(store, async () => { - expect((await cli.run(['log', title, '--body', body, '--cwd', cwd])).envelope).toMatchObject({ - meta: { cta: { commands: [{ command: 'frog list' }] } }, - ok: true, - }) + expect( + (await cli.run(['log', title, '--body', body, '--cwd', cwd], {}, { store })).envelope, + ).toMatchObject({ + meta: { cta: { commands: [{ command: 'frog list' }] } }, + ok: true, }) }) + +test('behavior: durable-store logging atomically records repeated titles', async () => { + const store = Store.postgres(fakePostgresClient(), { namespace: 'log-test' }) + const cwd = await helpers.repo() + + const first = await cli.data(['log', title, '--body', body, '--cwd', cwd], {}, { store }) + const repeated = await cli.data( + ['log', title.toUpperCase(), '--body', 'Later details.', '--cwd', cwd], + {}, + { store }, + ) + + expect(repeated.id).toBe(first.id) + expect(await store.records()).toMatchObject([{ occurrences: 2 }]) +}) const ownForm = [ 'name: Friction', 'body:', diff --git a/src/cli/commands/log.ts b/src/cli/commands/log.ts index c08c273..954a3c2 100644 --- a/src/cli/commands/log.ts +++ b/src/cli/commands/log.ts @@ -1,6 +1,7 @@ import * as clack from '@clack/prompts' import { Cli, z } from 'incur' import * as Entry from '../../Entry.js' +import * as Frog from '../../Frog.js' import * as IssueForm from '../../IssueForm.js' import * as Store from '../../Store.js' import * as Target from '../../Target.js' @@ -9,6 +10,7 @@ import * as context from '../internal/context.js' import * as form from '../internal/form.js' import * as prompt from '../internal/prompt.js' import * as publisher from '../internal/publish.js' +import * as environmentStore from '../internal/store.js' import * as stdin from '../internal/stdin.js' import * as target from '../internal/target.js' @@ -40,6 +42,7 @@ async function promptSeverity(): Promise { } export const log = Cli.create('log', { + vars: environmentStore.vars, description: 'Write a friction entry.', args: z.object({ title: z.string().min(1).optional().describe('One line, specific enough to search for.'), @@ -112,14 +115,15 @@ export const log = Cli.create('log', { }), async run(c) { const { config, repo, root } = await context.resolve({ cwd: c.options.cwd }) + const store = c.var.store ?? Store.file({ root }) const interactive = prompt.interactive() - if (c.options.publish && Store.activeName() !== 'file') + if (c.options.publish && store.name !== 'file') return c.error({ code: 'STORE_UNSUPPORTED_OPTION', message: '`--publish` is available only with the repository file store.', }) const opensEditor = c.options.open ?? (interactive && !c.options.body) - if (opensEditor && Store.activeName() !== 'file') + if (opensEditor && store.name !== 'file') return c.error({ code: 'STORE_UNSUPPORTED_OPTION', message: @@ -159,7 +163,7 @@ export const log = Cli.create('log', { // Always load this repository's configured form from disk so a supplied body cannot bypass it. const own = - ownTarget && Store.activeName() === 'file' + ownTarget && store.name === 'file' ? await attempt(form.own(root, { named: config.inbound.template })) : undefined @@ -231,13 +235,15 @@ export const log = Cli.create('log', { }, }) - const entries = await attempt(Store.read({ root })) - if (!entries.ok) return c.error({ code: entries.code, message: entries.message }) + const entries = store.log ? undefined : await attempt(store.read()) + if (entries && !entries.ok) return c.error({ code: entries.code, message: entries.message }) - // Catch the repeat at authoring time, the only point where it is cheap. - const duplicate = entries.value.find( - (entry) => Entry.normalizeTitle(entry.title) === Entry.normalizeTitle(title), - ) + // Stores without atomic logging still catch the repeat at authoring time, where it is cheap. + const duplicate = entries?.ok + ? entries.value.find( + (entry) => Entry.normalizeTitle(entry.title) === Entry.normalizeTitle(title), + ) + : undefined if (duplicate && !c.options.force) return c.error({ code: 'DUPLICATE_FRICTION', @@ -255,16 +261,21 @@ export const log = Cli.create('log', { const severity = c.options.severity ?? (promptedSeverity?.ok ? promptedSeverity.value : 'minor') - const { file, id } = await Store.write( - { - body: body ?? scaffold ?? Entry.template, - severity, - title, - ...(c.options.label?.length ? { labels: c.options.label } : {}), - ...(c.options.target ? { target: c.options.target } : {}), - }, - { root }, + const logged = await attempt( + Frog.create({ store }).log( + { + body: body ?? scaffold ?? Entry.template, + severity, + title, + ...(c.options.label?.length ? { labels: c.options.label } : {}), + ...(c.options.target ? { target: c.options.target } : {}), + }, + { force: c.options.force }, + ), ) + if (!logged.ok) return c.error({ code: logged.code, message: logged.message }) + const { id } = logged.value.entry + const file = logged.value.location // Reached interactively, or on request. The editor is the long-form input path. if (c.options.open ?? (interactive && !body)) { @@ -272,7 +283,7 @@ export const log = Cli.create('log', { prompt .edit(`${root}/${file}`, { command: c.env.VISUAL ?? c.env.EDITOR ?? 'vi' }) // Re-read so a body broken in the editor fails here rather than at publish time. - .then(() => Store.get(id, { root })), + .then(() => store.get(id)), ) if (!edited.ok) return c.error({ code: edited.code, message: edited.message }) const editedBodyError = validateBody(edited.value.body) @@ -282,7 +293,7 @@ export const log = Cli.create('log', { if (!c.options.publish) return c.ok( { - ...(Store.activeName() === 'file' ? { artifacts: Store.toArtifacts(id) } : {}), + ...(store.name === 'file' ? { artifacts: Store.toArtifacts(id) } : {}), file, id, title, @@ -291,7 +302,7 @@ export const log = Cli.create('log', { cta: { commands: [ { command: 'list', description: 'See everything recorded' }, - ...(Store.activeName() === 'file' + ...(store.name === 'file' ? [{ command: 'publish', description: 'File it as an issue now' }] : []), ], @@ -300,7 +311,7 @@ export const log = Cli.create('log', { }, ) - const entry = await attempt(Store.get(id, { root })) + const entry = await attempt(store.get(id)) if (!entry.ok) return c.error({ code: entry.code, message: entry.message }) const publishBodyError = validateBody(entry.value.body) if (publishBodyError) return c.error(publishBodyError) diff --git a/src/cli/commands/migrate.test.ts b/src/cli/commands/migrate.test.ts index 47f39da..17674da 100644 --- a/src/cli/commands/migrate.test.ts +++ b/src/cli/commands/migrate.test.ts @@ -7,23 +7,22 @@ test('behavior: the file store reports that it needs no migration', async () => test('behavior: delegates migration to the selected store', async () => { let calls = 0 - const adapter: Store.Adapter = { + const store = Store.from({ name: 'test', migrate: async () => { calls++ }, read: async () => [], - list: async () => [], get: async () => { throw new Error('unused') }, write: async () => ({ id: 'unused', location: 'unused' }), remove: async () => false, - files: async () => [], - } + }) - await Store.withAdapter(adapter, async () => { - await expect(cli.data(['migrate'])).resolves.toEqual({ migrated: true, store: 'test' }) + await expect(cli.data(['migrate'], {}, { store })).resolves.toEqual({ + migrated: true, + store: 'test', }) expect(calls).toBe(1) }) diff --git a/src/cli/commands/migrate.ts b/src/cli/commands/migrate.ts index 049328d..f1ee903 100644 --- a/src/cli/commands/migrate.ts +++ b/src/cli/commands/migrate.ts @@ -1,10 +1,14 @@ import { Cli, z } from 'incur' import * as Store from '../../Store.js' +import * as environmentStore from '../internal/store.js' export const migrate = Cli.create('migrate', { + vars: environmentStore.vars, description: 'Prepare the selected store.', output: z.object({ migrated: z.boolean(), store: z.string() }), - async run() { - return { migrated: await Store.migrate(), store: Store.activeName() } + async run(c) { + const store = c.var.store ?? Store.file({ root: process.cwd() }) + await store.migrate() + return { migrated: store.name !== 'file', store: store.name } }, }) diff --git a/src/cli/commands/publish.test.ts b/src/cli/commands/publish.test.ts index 17bf084..baa99b2 100644 --- a/src/cli/commands/publish.test.ts +++ b/src/cli/commands/publish.test.ts @@ -3,10 +3,9 @@ import path from 'node:path' import * as cli from '../../../test/cli.js' import { github } from '../../../test/github.js' import * as helpers from '../../../test/helpers.js' -import { FakePostgresClient } from '../../../test/postgres.js' +import { fakePostgresClient } from '../../../test/postgres.js' import * as Config from '../../Config.js' import * as Github from '../../Github.js' -import * as PostgresStore from '../../PostgresStore.js' import * as Store from '../../Store.js' const repo = 'wevm/demo' @@ -26,15 +25,12 @@ function env(url: string): Record { } test('error: repository publishing requires the file store', async () => { - const store = PostgresStore.adapter({ - client: new FakePostgresClient(), - namespace: 'publish-test', - }) + const store = Store.postgres(fakePostgresClient(), { namespace: 'publish-test' }) const cwd = await helpers.repo({ remote }) - await Store.withAdapter(store, async () => { - expect((await cli.error(['publish', '--cwd', cwd])).code).toBe('STORE_UNSUPPORTED_COMMAND') - }) + expect((await cli.error(['publish', '--cwd', cwd], {}, { store })).code).toBe( + 'STORE_UNSUPPORTED_COMMAND', + ) }) test('behavior: files a pending entry and writes the link back', async () => { diff --git a/src/cli/commands/publish.ts b/src/cli/commands/publish.ts index 9c6d0f9..dd6d968 100644 --- a/src/cli/commands/publish.ts +++ b/src/cli/commands/publish.ts @@ -7,6 +7,7 @@ import * as Target from '../../Target.js' import { attempt } from '../internal/attempt.js' import * as context from '../internal/context.js' import * as publisher from '../internal/publish.js' +import * as environmentStore from '../internal/store.js' import * as target from '../internal/target.js' /** Normalizes `--pr` into `owner/name#number`, accepting a bare number. */ @@ -15,6 +16,7 @@ function toPr(value: string, repo: string): string { } export const publish = Cli.create('publish', { + vars: environmentStore.vars, description: 'Publish friction entries as GitHub issues.', env: z.object({ GH_TOKEN: z.string().optional().describe('Fallback when GITHUB_TOKEN is unset.'), @@ -73,15 +75,16 @@ export const publish = Cli.create('publish', { }), async run(c) { const { config, repo, root } = await context.resolve({ cwd: c.options.cwd }) + const store = c.var.store ?? Store.file({ root }) - if (Store.activeName() !== 'file') + if (store.name !== 'file') return c.error({ code: 'STORE_UNSUPPORTED_COMMAND', message: '`publish` requires the repository file store because issue reconciliation is repository-owned.', }) - const entries = await attempt(Store.read({ root })) + const entries = await attempt(store.read()) if (!entries.ok) return c.error({ code: entries.code, message: entries.message }) const deferred: { code: string; id: string; reason: string }[] = [] diff --git a/src/cli/commands/resolve.test.ts b/src/cli/commands/resolve.test.ts index b9ad6e3..8a87c13 100644 --- a/src/cli/commands/resolve.test.ts +++ b/src/cli/commands/resolve.test.ts @@ -1,3 +1,4 @@ +import * as fs from 'node:fs/promises' import * as cli from '../../../test/cli.js' import * as helpers from '../../../test/helpers.js' import * as Store from '../../Store.js' @@ -19,3 +20,14 @@ test('behavior: removes one resolved entry', async () => { removed: false, }) }) + +test('security: rejects path traversal without removing parent directories', async () => { + const cwd = await helpers.repo() + await helpers.writeFile('.agents/keep.txt', 'keep', cwd) + + await expect(cli.error(['resolve', '..', '--cwd', cwd])).resolves.toEqual({ + code: 'INVALID_ENTRY_ID', + message: 'Entry id must be one path-safe directory name.', + }) + await expect(fs.readFile(`${cwd}/.agents/keep.txt`, 'utf8')).resolves.toBe('keep') +}) diff --git a/src/cli/commands/resolve.ts b/src/cli/commands/resolve.ts index 47a4ec1..4e9443e 100644 --- a/src/cli/commands/resolve.ts +++ b/src/cli/commands/resolve.ts @@ -1,8 +1,10 @@ import { Cli, z } from 'incur' import * as Store from '../../Store.js' import * as context from '../internal/context.js' +import * as environmentStore from '../internal/store.js' export const resolve = Cli.create('resolve', { + vars: environmentStore.vars, description: 'Remove one resolved friction entry.', args: z.object({ id: z.string().min(1).describe('Exact entry id from `frog list`.') }), options: z.object({ cwd: context.cwdOption }), @@ -10,6 +12,12 @@ export const resolve = Cli.create('resolve', { output: z.object({ id: z.string(), removed: z.boolean() }), async run(c) { const { root } = await context.resolve({ cwd: c.options.cwd }) - return { id: c.args.id, removed: await Store.remove(c.args.id, { root }) } + const store = c.var.store ?? Store.file({ root }) + if (!Store.isId(c.args.id)) + return c.error({ + code: 'INVALID_ENTRY_ID', + message: 'Entry id must be one path-safe directory name.', + }) + return { id: c.args.id, removed: await store.remove(c.args.id) } }, }) diff --git a/src/cli/commands/sync.test.ts b/src/cli/commands/sync.test.ts index 9b0f01e..48aacb1 100644 --- a/src/cli/commands/sync.test.ts +++ b/src/cli/commands/sync.test.ts @@ -3,12 +3,11 @@ import path from 'node:path' import * as cli from '../../../test/cli.js' import { github } from '../../../test/github.js' import * as helpers from '../../../test/helpers.js' -import { FakePostgresClient } from '../../../test/postgres.js' +import { fakePostgresClient } from '../../../test/postgres.js' import * as AppSync from '../../AppSync.js' import * as Entry from '../../Entry.js' import * as Github from '../../Github.js' import * as Mirrors from '../../Mirrors.js' -import * as PostgresStore from '../../PostgresStore.js' import * as Store from '../../Store.js' const repo = 'wevm/demo' @@ -29,12 +28,12 @@ function env(url: string): Record { } test('error: repository reconciliation requires the file store', async () => { - const store = PostgresStore.adapter({ client: new FakePostgresClient(), namespace: 'sync-test' }) + const store = Store.postgres(fakePostgresClient(), { namespace: 'sync-test' }) const cwd = await helpers.repo({ remote }) - await Store.withAdapter(store, async () => { - expect((await cli.error(['sync', '--cwd', cwd])).code).toBe('STORE_UNSUPPORTED_COMMAND') - }) + expect((await cli.error(['sync', '--cwd', cwd], {}, { store })).code).toBe( + 'STORE_UNSUPPORTED_COMMAND', + ) }) function issueBody(id: string, body = 'Body.', severity?: Entry.Severity): string { diff --git a/src/cli/commands/sync.ts b/src/cli/commands/sync.ts index 0059bb8..53c6260 100644 --- a/src/cli/commands/sync.ts +++ b/src/cli/commands/sync.ts @@ -10,8 +10,10 @@ import * as Sync from '../../Sync.js' import { attempt } from '../internal/attempt.js' import * as context from '../internal/context.js' import * as publisher from '../internal/publish.js' +import * as environmentStore from '../internal/store.js' export const sync = Cli.create('sync', { + vars: environmentStore.vars, description: 'Reconcile entries against issue state.', env: z.object({ GH_TOKEN: z.string().optional().describe('Fallback when GITHUB_TOKEN is unset.'), @@ -69,15 +71,16 @@ export const sync = Cli.create('sync', { }), async run(c) { const { config, repo, root } = await context.resolve({ cwd: c.options.cwd }) + const store = c.var.store ?? Store.file({ root }) - if (Store.activeName() !== 'file') + if (store.name !== 'file') return c.error({ code: 'STORE_UNSUPPORTED_COMMAND', message: '`sync` requires the repository file store because reconciliation mirrors are repository-owned.', }) - const entries = await attempt(Store.read({ root })) + const entries = await attempt(store.read()) if (!entries.ok) return c.error({ code: entries.code, message: entries.message }) const mirrors = await attempt(Mirrors.resolve({ root })) if (!mirrors.ok) return c.error({ code: mirrors.code, message: mirrors.message }) @@ -190,9 +193,9 @@ export const sync = Cli.create('sync', { }) await Git.rm(plan.remove.map(Store.toDir), { cwd: root, ignoreUnmatch: true }) - for (const id of plan.remove) await Store.remove(id, { root }) + for (const id of plan.remove) await store.remove(id) for (const entry of [...plan.write, ...plan.clearLink]) - await Store.write(entry, { id: entry.id, root }) + await store.write(entry, { id: entry.id }) if (mirrorsChanged) await Mirrors.write(nextMirrors, { root }) const touched = [...plan.write, ...plan.clearLink].map((entry) => Store.toPath(entry.id)) @@ -374,10 +377,10 @@ export const sync = Cli.create('sync', { // goes, artifacts included. `ignoreUnmatch` covers entries that were never committed; those are // removed from disk below. await Git.rm(removedIds.map(Store.toDir), { cwd: root, ignoreUnmatch: true }) - for (const id of removedIds) await Store.remove(id, { root }) + for (const id of removedIds) await store.remove(id) for (const entry of [...plan.write, ...plan.clearLink]) - await Store.write(entry, { id: entry.id, root }) + await store.write(entry, { id: entry.id }) if (mirrorsChanged) await Mirrors.write(nextMirrors, { root }) const touched = [...plan.write, ...plan.clearLink].map((entry) => Store.toPath(entry.id)) diff --git a/src/cli/internal/store.ts b/src/cli/internal/store.ts index 7653812..8b13074 100644 --- a/src/cli/internal/store.ts +++ b/src/cli/internal/store.ts @@ -1,11 +1,14 @@ import { createRequire } from 'node:module' -import * as PostgresStore from '../../PostgresStore.js' -import type * as Store from '../../Store.js' +import { z } from 'incur' +import * as Store from '../../Store.js' export type Environment = Record +/** Middleware variables shared by commands that persist friction. */ +export const vars = z.object({ store: z.custom().optional() }) + export type Selection = { - adapter: Store.Adapter + store: Store.Store close(): Promise } @@ -38,7 +41,7 @@ export async function resolve(env: Environment): Promise if (selected.kind === 'file') return undefined const require = createRequire(import.meta.url) - let Pool: new (options: { connectionString: string }) => PostgresStore.Client & { + let Pool: new (options: { connectionString: string }) => Store.postgres.Client & { end(): Promise } try { @@ -48,8 +51,7 @@ export async function resolve(env: Environment): Promise } const client = new Pool({ connectionString: selected.connectionString }) return { - adapter: PostgresStore.adapter({ - client, + store: Store.postgres(client, { namespace: selected.namespace, ...(selected.schema ? { schema: selected.schema } : {}), }), diff --git a/src/index.ts b/src/index.ts index da8e520..044f00c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,8 +11,7 @@ export * as Config from './Config.js' export * as Entry from './Entry.js' /** Storage-independent friction logging for embedded consumers. */ -export { FrictionLog } from './FrictionLog.js' -export type { Adapter as FrictionStore, RecordResult, StoredEntry } from './FrictionLog.js' +export * as Frog from './Frog.js' /** Parses a project's GitHub issue form and renders the entry scaffold it implies. */ export * as IssueForm from './IssueForm.js' @@ -29,10 +28,7 @@ export * as Mirrors from './Mirrors.js' /** Reading and writing entries under `.agents/friction-log`. */ export * as Store from './Store.js' -/** Optional Postgres store accepting any `pg`-compatible client. */ -export * as PostgresStore from './PostgresStore.js' - -/** Reconciling local entries against issue state, as a pure plan both adapters can apply. */ +/** Reconciling local entries against issue state, as a pure plan both stores can apply. */ export * as Sync from './Sync.js' /** Resolving where an entry's issue belongs, and every consent gate on the way. */ diff --git a/test/cli.ts b/test/cli.ts index 35d8935..73d1f11 100644 --- a/test/cli.ts +++ b/test/cli.ts @@ -1,4 +1,5 @@ import { serve } from '../src/cli/Cli.js' +import type * as Store from '../src/Store.js' export type Result = { /** Exit code, or `undefined` when the command never exited non-zero. */ @@ -9,6 +10,11 @@ export type Result = { stdout: string } +export type Options = { + /** Store injected into commands that persist friction. */ + store?: Store.Store | undefined +} + type Envelope = | { data: Record; meta?: Record; ok: true } | { error: { code: string; message: string }; meta?: Record; ok: false } @@ -22,11 +28,13 @@ type Envelope = export async function run( argv: readonly string[], env: Record = {}, + options: Options = {}, ): Promise { let stdout = '' let code: number | undefined await serve([...argv, '--json', '--full-output'], { env, + ...options, exit(value) { code ??= value }, @@ -41,8 +49,9 @@ export async function run( export async function data>( argv: readonly string[], env?: Record, + options?: Options, ): Promise { - const result = await run(argv, env) + const result = await run(argv, env, options) if (!result.envelope.ok) throw new Error(`Expected success, got ${result.envelope.error.code}: ${result.stdout}`) return result.envelope.data as value @@ -52,8 +61,9 @@ export async function data>( export async function error( argv: readonly string[], env?: Record, + options?: Options, ): Promise<{ code: string; message: string }> { - const result = await run(argv, env) + const result = await run(argv, env, options) if (result.envelope.ok) throw new Error(`Expected failure, got ${result.stdout}`) return result.envelope.error } diff --git a/test/postgres.ts b/test/postgres.ts index 1a17a35..81b03d3 100644 --- a/test/postgres.ts +++ b/test/postgres.ts @@ -1,99 +1,114 @@ -import type * as PostgresStore from '../src/PostgresStore.js' +import type * as Store from '../src/Store.js' type Stored = { contents: string; dedupeKey: string; id: string; occurrences: number } -/** Small behavioral Postgres client used by adapter and contract tests. */ -export class FakePostgresClient implements PostgresStore.Client { - readonly queries: string[] = [] - readonly rows = new Map() +/** Small behavioral Postgres client used by store and contract tests. */ +export type FakePostgresClient = Store.postgres.Client & { + /** SQL statements issued through the client. */ + readonly queries: string[] + /** Rows persisted by the fake client. */ + readonly rows: Map +} - async query = Record>( - text: string, - values: unknown[] = [], - ): Promise<{ rowCount: number; rows: T[] }> { - this.queries.push(text) - if (text.startsWith('CREATE ')) return { rowCount: 0, rows: [] } +/** Creates a behavioral Postgres client for store and contract tests. */ +export function fakePostgresClient(): FakePostgresClient { + const queries: string[] = [] + const rows = new Map() + return { + queries, + rows, + async query = Record>( + text: string, + values: unknown[] = [], + ): Promise<{ rowCount: number; rows: T[] }> { + queries.push(text) + if (text.startsWith('CREATE ')) return { rowCount: 0, rows: [] } - const namespace = String(values[0]) - const key = (id: string) => `${namespace}\u0000${id}` - if (text.includes('ON CONFLICT(namespace, dedupe_key)')) { - const [, rawId, rawDedupe, rawContents] = values - const id = String(rawId) - const dedupeKey = String(rawDedupe) - const existing = [...this.rows.entries()].find( - ([storedKey, row]) => - storedKey.startsWith(`${namespace}\u0000`) && row.dedupeKey === dedupeKey, - )?.[1] - if (existing) { - existing.occurrences += 1 + const namespace = String(values[0]) + const key = (id: string) => `${namespace}\u0000${id}` + if (text.includes('ON CONFLICT(namespace, dedupe_key)')) { + const [, rawId, rawDedupe, rawContents] = values + const id = String(rawId) + const dedupeKey = String(rawDedupe) + const existing = [...rows.entries()].find( + ([storedKey, row]) => + storedKey.startsWith(`${namespace}\u0000`) && row.dedupeKey === dedupeKey, + )?.[1] + if (existing) { + existing.occurrences += 1 + return { + rowCount: 1, + rows: [ + { + contents: existing.contents, + created: false, + id: existing.id, + occurrence_count: existing.occurrences, + } as unknown as T, + ], + } + } + const stored = { contents: String(rawContents), dedupeKey, id, occurrences: 1 } + rows.set(key(id), stored) return { rowCount: 1, rows: [ - { - contents: existing.contents, - created: false, - id: existing.id, - occurrence_count: existing.occurrences, - } as unknown as T, + { contents: stored.contents, created: true, id, occurrence_count: 1 } as unknown as T, ], } } - const stored = { contents: String(rawContents), dedupeKey, id, occurrences: 1 } - this.rows.set(key(id), stored) - return { - rowCount: 1, - rows: [ - { contents: stored.contents, created: true, id, occurrence_count: 1 } as unknown as T, - ], + if (text.startsWith('INSERT INTO')) { + const [, rawId, rawDedupe, rawContents, rawTitleDedupe] = values + const id = String(rawId) + const previous = rows.get(key(id)) + rows.set(key(id), { + contents: String(rawContents), + dedupeKey: + previous?.dedupeKey.startsWith('title:') && typeof rawTitleDedupe === 'string' + ? rawTitleDedupe + : (previous?.dedupeKey ?? String(rawDedupe)), + id, + occurrences: previous?.occurrences ?? 1, + }) + return { rowCount: 1, rows: [] } } - } - if (text.startsWith('INSERT INTO')) { - const [, rawId, rawDedupe, rawContents, rawTitleDedupe] = values - const id = String(rawId) - const previous = this.rows.get(key(id)) - this.rows.set(key(id), { - contents: String(rawContents), - dedupeKey: - previous?.dedupeKey.startsWith('title:') && typeof rawTitleDedupe === 'string' - ? rawTitleDedupe - : (previous?.dedupeKey ?? String(rawDedupe)), - id, - occurrences: previous?.occurrences ?? 1, - }) - return { rowCount: 1, rows: [] } - } - if (text.startsWith('SELECT id, contents')) { - const selected = - typeof values[1] === 'string' - ? [this.rows.get(key(values[1]))].filter(Boolean) - : [...this.rows.entries()] - .filter(([storedKey]) => storedKey.startsWith(`${namespace}\u0000`)) - .map(([, row]) => row) - return { - rowCount: selected.length, - rows: selected.map( - (row) => - ({ - contents: row!.contents, - id: row!.id, - occurrence_count: row!.occurrences, - }) as unknown as T, - ), + if (text.startsWith('SELECT id, contents')) { + const selected = + typeof values[1] === 'string' + ? [rows.get(key(values[1]))].filter(Boolean) + : [...rows.entries()] + .filter(([storedKey]) => storedKey.startsWith(`${namespace}\u0000`)) + .map(([, row]) => row) + return { + rowCount: selected.length, + rows: selected.map( + (row) => + ({ + contents: row!.contents, + id: row!.id, + occurrence_count: row!.occurrences, + }) as unknown as T, + ), + } } - } - if (text.startsWith('SELECT id FROM')) { - const selected = [...this.rows.entries()].filter(([storedKey]) => - storedKey.startsWith(`${namespace}\u0000`), - ) - return { - rowCount: selected.length, - rows: selected.map(([, row]) => ({ id: row.id }) as unknown as T), + if (text.startsWith('SELECT id FROM')) { + const selected = [...rows.entries()].filter(([storedKey]) => + storedKey.startsWith(`${namespace}\u0000`), + ) + return { + rowCount: selected.length, + rows: selected.map(([, row]) => ({ id: row.id }) as unknown as T), + } + } + if (text.startsWith('DELETE FROM')) { + const id = String(values[1]) + const removed = rows.delete(key(id)) + return { + rowCount: removed ? 1 : 0, + rows: removed ? ([{ id }] as unknown as T[]) : [], + } } - } - if (text.startsWith('DELETE FROM')) { - const removed = this.rows.delete(key(String(values[1]))) - return { rowCount: removed ? 1 : 0, rows: [] } - } - throw new Error(`Unhandled SQL: ${text}`) + throw new Error(`Unhandled SQL: ${text}`) + }, } } diff --git a/test/storeContract.ts b/test/storeContract.ts index 60ff5b0..e1fcab3 100644 --- a/test/storeContract.ts +++ b/test/storeContract.ts @@ -16,7 +16,7 @@ const canonicalEntry = { } as const satisfies Entry.serialize.Options /** Runs the canonical persistence contract against a store implementation. */ -export function storeContract(name: string, create: () => Promise) { +export function storeContract(name: string, create: () => Promise) { describe(`${name} store contract`, () => { test('preserves the complete canonical entry schema', async () => { const store = await create() @@ -27,7 +27,7 @@ export function storeContract(name: string, create: () => Promise expect(await store.get(written.id)).toEqual({ ...canonicalEntry, id: written.id }) expect(await store.read()).toEqual([{ ...canonicalEntry, id: written.id }]) expect(await store.list()).toEqual([written.id]) - if (store.files) expect(await store.files(written.id)).toEqual(expect.any(Array)) + expect(await store.files(written.id)).toEqual(expect.any(Array)) const updated = { ...canonicalEntry, From 3051a8496ca16fde112f3d499e100431c3254863 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:54:58 +1000 Subject: [PATCH 10/26] test: use postgres testcontainers --- package.json | 3 + pnpm-lock.yaml | 1169 +++++++++++++++++++++++++++++- pnpm-workspace.yaml | 6 + src/Store.postgres.test.ts | 55 +- src/cli/commands/list.test.ts | 16 +- src/cli/commands/log.test.ts | 9 +- src/cli/commands/publish.test.ts | 11 +- src/cli/commands/sync.test.ts | 11 +- test/postgres.ts | 160 ++-- 9 files changed, 1293 insertions(+), 147 deletions(-) diff --git a/package.json b/package.json index ea70cd0..78345d8 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,11 @@ }, "devDependencies": { "@changesets/cli": "catalog:", + "@testcontainers/postgresql": "catalog:", "@types/node": "catalog:", + "@types/pg": "catalog:", "@vitest/coverage-v8": "catalog:", + "pg": "catalog:", "tsx": "catalog:", "typescript": "catalog:", "vite-plus": "catalog:", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5c31c71..5c9ebc4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,9 +18,15 @@ catalogs: '@octokit/rest': specifier: ^22.0.1 version: 22.0.1 + '@testcontainers/postgresql': + specifier: ^12.0.4 + version: 12.0.4 '@types/node': specifier: latest version: 26.1.1 + '@types/pg': + specifier: ^8.20.3 + version: 8.20.3 '@vitest/coverage-v8': specifier: latest version: 4.1.10 @@ -30,6 +36,9 @@ catalogs: octokit: specifier: ^5.0.5 version: 5.0.5 + pg: + specifier: ^8.22.0 + version: 8.22.0 tsx: specifier: ^4.23.1 version: 4.23.1 @@ -62,9 +71,6 @@ importers: incur: specifier: 'catalog:' version: 0.4.25 - pg: - specifier: '>=8.0.0' - version: 8.22.0 yaml: specifier: 'catalog:' version: 2.9.0 @@ -72,12 +78,21 @@ importers: '@changesets/cli': specifier: 'catalog:' version: 2.31.1(@types/node@26.1.1) + '@testcontainers/postgresql': + specifier: 'catalog:' + version: 12.0.4(supports-color@10.2.2) '@types/node': specifier: 'catalog:' version: 26.1.1 + '@types/pg': + specifier: 'catalog:' + version: 8.20.3 '@vitest/coverage-v8': specifier: 'catalog:' version: 4.1.10(@vitest/browser@4.1.10)(vitest@4.1.10) + pg: + specifier: 'catalog:' + version: 8.22.0 tsx: specifier: 'catalog:' version: 4.23.1 @@ -143,6 +158,9 @@ packages: resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} + '@balena/dockerignore@1.0.2': + resolution: {integrity: sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==} + '@bcoe/v8-coverage@1.0.2': resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} @@ -431,6 +449,20 @@ packages: cpu: [x64] os: [win32] + '@grpc/grpc-js@1.14.4': + resolution: {integrity: sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==} + engines: {node: '>=12.10.0'} + + '@grpc/proto-loader@0.7.15': + resolution: {integrity: sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==} + engines: {node: '>=6'} + hasBin: true + + '@grpc/proto-loader@0.8.1': + resolution: {integrity: sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==} + engines: {node: '>=6'} + hasBin: true + '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} @@ -602,6 +634,10 @@ packages: '@types/node': optional: true + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -615,6 +651,12 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@js-sdsl/ordered-map@4.4.2': + resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} + + '@kwsites/file-exists@1.1.1': + resolution: {integrity: sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==} + '@manypkg/find-root@1.1.0': resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==} @@ -1170,6 +1212,10 @@ packages: resolution: {integrity: sha512-OhgMQeMmZA0dcFcX4/priaJZWdFECxiClgq6mRX6aatZEcV9PbKC3P3/v8U1hVjviT1i5U+vR8lAtBV6m4FXAA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -1182,6 +1228,33 @@ packages: '@poppinss/exception@1.2.3': resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.5': + resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} + + '@protobufjs/eventemitter@1.1.1': + resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==} + + '@protobufjs/fetch@1.1.1': + resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.2': + resolution: {integrity: sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==} + '@rolldown/binding-android-arm64@1.1.5': resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1294,6 +1367,9 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@testcontainers/postgresql@12.0.4': + resolution: {integrity: sha512-a/pLU6j5lpKKAlUTPwqweqMGhOSjgTSb6HBX69TOrXn32ifU37nnQDmNFTj8ddOAw+BQL9oTRkeOxVbZkqhgZA==} + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -1322,15 +1398,36 @@ packages: '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/docker-modem@3.0.6': + resolution: {integrity: sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg==} + + '@types/dockerode@4.0.1': + resolution: {integrity: sha512-cmUpB+dPN955PxBEuXE3f6lKO1hHiIGYJA46IVF3BJpNsZGvtBDcRnlrHYHtOH/B6vtDOyl2kZ2ShAu3mgc27Q==} + '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} + '@types/node@18.19.130': + resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} + '@types/node@26.1.1': resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==} + '@types/pg@8.20.3': + resolution: {integrity: sha512-4Tvg+HO6+oQaAkpT8GTYoSExzpGGZz532GXgbbCElWJQeQdMozBWxEKNBhJJpHFjWXsMxqPbyypvj/89FWNoSQ==} + + '@types/ssh2-streams@0.1.13': + resolution: {integrity: sha512-faHyY3brO9oLEA0QlcO8N2wT7R0+1sHWZvQ+y3rMLwdY1ZyS1z0W3t65j9PqT4HmQ6ALzNe7RZlNuCNE0wBSWA==} + + '@types/ssh2@0.5.52': + resolution: {integrity: sha512-lbLLlXxdCZOSJMCInKH2+9V/77ET2J6NPQHpFI0kda61Dd1KglJs+fPQBchizmzYSOJBgdTajhPqBO1xxLywvg==} + + '@types/ssh2@1.15.5': + resolution: {integrity: sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==} + '@typescript/typescript-aix-ppc64@7.0.2': resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} engines: {node: '>=16.20.0'} @@ -1733,6 +1830,10 @@ packages: '@yuku-toolchain/types@0.5.43': resolution: {integrity: sha512-kSpvPntnXw5+lYjO71ffBEnQ5ycQ74KGIYknh0TS4xeyCuBkOqxyJumxZkMhLBBUCLjDAbx2+Icnr3Zh4ftjpQ==} + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} @@ -1741,10 +1842,30 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + ansi-styles@5.2.0: resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} engines: {node: '>=10'} + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + archiver-utils@5.0.2: + resolution: {integrity: sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==} + engines: {node: '>= 14'} + + archiver@7.0.1: + resolution: {integrity: sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==} + engines: {node: '>= 14'} + argparse@1.0.10: resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} @@ -1758,6 +1879,9 @@ packages: resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} engines: {node: '>=8'} + asn1@0.2.6: + resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -1765,6 +1889,66 @@ packages: ast-v8-to-istanbul@1.0.5: resolution: {integrity: sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==} + async-lock@1.4.1: + resolution: {integrity: sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==} + + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + + b4a@1.8.1: + resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + bare-events@2.9.1: + resolution: {integrity: sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + + bare-fs@4.7.4: + resolution: {integrity: sha512-y1kC+ffIx/tPLdTE693uNjHfzTfr+ravR5tvWlMXe25nELbkqV400S71qHDwbkAQ1FVEZobB1NFRzFbCCcyBCQ==} + engines: {bare: '>=1.16.0'} + peerDependencies: + bare-buffer: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + + bare-path@3.1.1: + resolution: {integrity: sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==} + + bare-stream@2.13.3: + resolution: {integrity: sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==} + peerDependencies: + bare-abort-controller: '*' + bare-buffer: '*' + bare-events: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + bare-buffer: + optional: true + bare-events: + optional: true + + bare-url@2.4.6: + resolution: {integrity: sha512-iQxPClE07hETVpbRoX7JXX3v/ZQViCxe/SYCxylRLzdEx1xJAufPptfiOqR8tqiCtmbtMDANKWszzjLu1PMAZQ==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + bcrypt-pbkdf@1.0.2: + resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} + before-after-hook@4.0.0: resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==} @@ -1772,16 +1956,40 @@ packages: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} engines: {node: '>=4'} + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + blake3-wasm@2.1.5: resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} bottleneck@2.19.5: resolution: {integrity: sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==} + brace-expansion@2.1.4: + resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + buffer-crc32@1.0.0: + resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==} + engines: {node: '>=8.0.0'} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + + buildcheck@0.0.7: + resolution: {integrity: sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==} + engines: {node: '>=10.0.0'} + + byline@5.0.0: + resolution: {integrity: sha512-s6webAy+R4SR8XVuJWt2V2rGvhnrhxN+9S15GNuTK3wKPOXFF6RNc+8ug2XhH+2s4f+uudG4kUVYmYOQWL2g0Q==} + engines: {node: '>=0.10.0'} + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} @@ -1793,6 +2001,24 @@ packages: chardet@2.2.0: resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==} + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + compress-commons@6.0.2: + resolution: {integrity: sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==} + engines: {node: '>= 14'} + content-type@2.0.0: resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} engines: {node: '>=18'} @@ -1804,10 +2030,35 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + cpu-features@0.0.10: + resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==} + engines: {node: '>=10.0.0'} + + crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + + crc32-stream@6.0.0: + resolution: {integrity: sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==} + engines: {node: '>= 14'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -1824,9 +2075,33 @@ packages: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} + docker-compose@1.4.2: + resolution: {integrity: sha512-rPHigTKGaEHpkUmfd69QgaOp+Os5vGJwG/Ry8lcr8W/382AmI+z/D7qoa9BybKIkqNppaIbs8RYeHSevdQjWww==} + engines: {node: '>= 6.0.0'} + + docker-modem@5.0.7: + resolution: {integrity: sha512-XJgGhoR/CLpqshm4d3L7rzH6t8NgDFUIIpztYlLHIApeJjMZKYJMz2zxPsYxnejq5h3ELYSw/RBsi3t5h7gNTA==} + engines: {node: '>= 8.0'} + + dockerode@5.0.1: + resolution: {integrity: sha512-avsq/xk4YPIrn0CgleX5bjT9Y8IT1p9PxrNQ++RBQ2WEyFfHCTDsT9kmyxz+H/axnjAwg8wJWEIuPGOUuNupiA==} + engines: {node: '>= 14.17'} + dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + enquirer@2.4.1: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} engines: {node: '>=8.6'} @@ -1842,6 +2117,10 @@ packages: engines: {node: '>=18'} hasBin: true + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} @@ -1850,6 +2129,17 @@ packages: estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + expect-type@1.4.0: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} @@ -1857,6 +2147,9 @@ packages: extendable-error@0.1.7: resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==} + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} @@ -1890,6 +2183,13 @@ packages: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + fs-extra@7.0.1: resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} engines: {node: '>=6 <7 || >=8'} @@ -1903,10 +2203,23 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-port@5.1.1: + resolution: {integrity: sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==} + engines: {node: '>=8'} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + globby@11.1.0: resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} engines: {node: '>=10'} @@ -1929,6 +2242,9 @@ packages: resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} engines: {node: '>=0.10.0'} + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1938,10 +2254,17 @@ packages: engines: {node: '>=22'} hasBin: true + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} @@ -1950,6 +2273,10 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + is-subdir@1.2.0: resolution: {integrity: sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==} engines: {node: '>=4'} @@ -1958,6 +2285,9 @@ packages: resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} engines: {node: '>=0.10.0'} + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -1973,6 +2303,9 @@ packages: resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} engines: {node: '>=8'} + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} @@ -1997,6 +2330,10 @@ packages: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} + lazystream@1.0.1: + resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} + engines: {node: '>= 0.6.3'} + lightningcss-android-arm64@1.33.0: resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} engines: {node: '>= 12.0.0'} @@ -2075,9 +2412,21 @@ packages: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + lodash.startcase@4.4.0: resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lz-string@1.5.0: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true @@ -2105,6 +2454,26 @@ packages: engines: {node: '>=22.0.0'} hasBin: true + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + + mkdirp@3.0.1: + resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==} + engines: {node: '>=10'} + hasBin: true + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -2113,11 +2482,21 @@ packages: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nan@2.28.0: + resolution: {integrity: sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==} + nanoid@3.3.16: resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + obug@2.1.4: resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} engines: {node: '>=12.20.0'} @@ -2126,6 +2505,9 @@ packages: resolution: {integrity: sha512-4+/OFSqOjoyULo7eN7EA97DE0Xydj/PW5aIckxqQIoFjFwqXKuFCvXUJObyJfBF9Khu4RL/jlDRI9FPaMGfPnw==} engines: {node: '>= 20'} + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + outdent@0.5.0: resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} @@ -2189,6 +2571,9 @@ packages: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + package-manager-detector@0.2.11: resolution: {integrity: sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==} @@ -2200,6 +2585,10 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + path-to-regexp@6.3.0: resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} @@ -2292,6 +2681,27 @@ packages: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + + proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + + properties-reader@3.0.1: + resolution: {integrity: sha512-WPn+h9RGEExOKdu4bsF4HksG/uzd3cFq3MFtq8PsFeExPse5Ha/VOjQNyHhjboBFwGXGev6muJYTSPAOkROq2g==} + engines: {node: '>=18'} + + protobufjs@7.6.5: + resolution: {integrity: sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==} + engines: {node: '>=12.0.0'} + + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} @@ -2305,10 +2715,32 @@ packages: resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==} engines: {node: '>=6'} + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readable-stream@4.7.0: + resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + readdir-glob@1.1.3: + resolution: {integrity: sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + resolve-from@5.0.0: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -2321,6 +2753,12 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -2344,6 +2782,9 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -2366,6 +2807,9 @@ packages: spawndamnit@3.0.1: resolution: {integrity: sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg==} + split-ca@1.0.1: + resolution: {integrity: sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==} + split2@4.2.0: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} @@ -2373,16 +2817,44 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + ssh-remote-port-forward@1.0.4: + resolution: {integrity: sha512-x0LV1eVDwjf1gmG7TTnfqIzf+3VPRz7vrNIjX6oYLbeCrf/PeVY6hkT68Mg+q02qXxQhrLjB0jfgvhevoCRmLQ==} + + ssh2@1.17.0: + resolution: {integrity: sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==} + engines: {node: '>=10.16.0'} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} std-env@4.2.0: resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + streamx@2.28.0: + resolution: {integrity: sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} @@ -2395,10 +2867,32 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + tar-fs@2.1.5: + resolution: {integrity: sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==} + + tar-fs@3.1.3: + resolution: {integrity: sha512-/hU4AXnIdZu+Gvl1pk0oI5f5HxWsCJRtY2aFaJdk9VvyL48DWU6iU5WAIPG+wIi1YvWA6eTJvIviP/tMAZZNwQ==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + + tar-stream@3.2.0: + resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==} + + teex@1.0.1: + resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} + term-size@2.2.1: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} engines: {node: '>=8'} + testcontainers@12.0.4: + resolution: {integrity: sha512-QIR/8xF1+F/26cIM+9B4yyxNTbKJxAv3hygZyhPRgZ8Q2AhlPZjDdpXRuk16V37X4bgJRI3hXFhoEICMBA7Adg==} + + text-decoder@1.2.7: + resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -2418,6 +2912,10 @@ packages: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} + tmp@0.2.7: + resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==} + engines: {node: '>=14.14'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -2452,11 +2950,17 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tweetnacl@0.14.5: + resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} + typescript@7.0.2: resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} engines: {node: '>=16.20.0'} hasBin: true + undici-types@5.26.5: + resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + undici-types@8.3.0: resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} @@ -2464,6 +2968,10 @@ packages: resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} engines: {node: '>=20.18.1'} + undici@8.9.0: + resolution: {integrity: sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==} + engines: {node: '>=22.19.0'} + unenv@2.0.0-rc.24: resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} @@ -2477,6 +2985,9 @@ packages: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + vite-plus@0.2.6: resolution: {integrity: sha512-fFX8GLENhtzvnE4NmTPC8INRxjD2kZKcqUW0p7jOdawmHTNZqM5FiieyWOG6WH1a/axu9FCsbUNb918grfzDTw==} engines: {node: ^20.19.0 || ^22.18.0 || >=24.11.0} @@ -2599,6 +3110,17 @@ packages: '@cloudflare/workers-types': optional: true + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.21.0: resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} engines: {node: '>=10.0.0'} @@ -2627,11 +3149,23 @@ packages: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + yaml@2.9.0: resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} engines: {node: '>= 14.6'} hasBin: true + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} + engines: {node: '>=12'} + youch-core@0.3.3: resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} @@ -2656,6 +3190,10 @@ packages: typescript: optional: true + zip-stream@6.0.1: + resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} + engines: {node: '>= 14'} + zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} @@ -2682,6 +3220,8 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@balena/dockerignore@1.0.2': {} + '@bcoe/v8-coverage@1.0.2': {} '@blazediff/core@1.9.1': {} @@ -2966,6 +3506,25 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true + '@grpc/grpc-js@1.14.4': + dependencies: + '@grpc/proto-loader': 0.8.1 + '@js-sdsl/ordered-map': 4.4.2 + + '@grpc/proto-loader@0.7.15': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.3.2 + protobufjs: 7.6.5 + yargs: 17.7.3 + + '@grpc/proto-loader@0.8.1': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.3.2 + protobufjs: 7.6.5 + yargs: 17.7.3 + '@img/colour@1.1.0': {} '@img/sharp-darwin-arm64@0.35.2': @@ -3079,6 +3638,15 @@ snapshots: optionalDependencies: '@types/node': 26.1.1 + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/sourcemap-codec@1.5.5': {} @@ -3093,6 +3661,14 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@js-sdsl/ordered-map@4.4.2': {} + + '@kwsites/file-exists@1.1.1(supports-color@10.2.2)': + dependencies: + debug: 4.4.3(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + '@manypkg/find-root@1.1.0': dependencies: '@babel/runtime': 7.29.7 @@ -3488,6 +4064,9 @@ snapshots: '@oxlint/plugins@1.73.0': {} + '@pkgjs/parseargs@0.11.0': + optional: true + '@polka/url@1.0.0-next.29': {} '@poppinss/colors@4.1.6': @@ -3502,6 +4081,26 @@ snapshots: '@poppinss/exception@1.2.3': {} + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.5': {} + + '@protobufjs/eventemitter@1.1.1': {} + + '@protobufjs/fetch@1.1.1': + dependencies: + '@protobufjs/aspromise': 1.1.2 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.2': {} + '@rolldown/binding-android-arm64@1.1.5': optional: true @@ -3561,6 +4160,15 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@testcontainers/postgresql@12.0.4(supports-color@10.2.2)': + dependencies: + testcontainers: 12.0.4(supports-color@10.2.2) + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + - supports-color + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.7 @@ -3594,14 +4202,48 @@ snapshots: '@types/deep-eql@4.0.2': {} + '@types/docker-modem@3.0.6': + dependencies: + '@types/node': 26.1.1 + '@types/ssh2': 1.15.5 + + '@types/dockerode@4.0.1': + dependencies: + '@types/docker-modem': 3.0.6 + '@types/node': 26.1.1 + '@types/ssh2': 1.15.5 + '@types/estree@1.0.9': {} '@types/node@12.20.55': {} + '@types/node@18.19.130': + dependencies: + undici-types: 5.26.5 + '@types/node@26.1.1': dependencies: undici-types: 8.3.0 + '@types/pg@8.20.3': + dependencies: + '@types/node': 26.1.1 + pg-protocol: 1.15.0 + pg-types: 2.2.0 + + '@types/ssh2-streams@0.1.13': + dependencies: + '@types/node': 26.1.1 + + '@types/ssh2@0.5.52': + dependencies: + '@types/node': 26.1.1 + '@types/ssh2-streams': 0.1.13 + + '@types/ssh2@1.15.5': + dependencies: + '@types/node': 18.19.130 + '@typescript/typescript-aix-ppc64@7.0.2': optional: true @@ -3856,12 +4498,48 @@ snapshots: '@yuku-toolchain/types@0.5.43': {} + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + ansi-colors@4.1.3: {} ansi-regex@5.0.1: {} + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} + + archiver-utils@5.0.2: + dependencies: + glob: 10.5.0 + graceful-fs: 4.2.11 + is-stream: 2.0.1 + lazystream: 1.0.1 + lodash: 4.18.1 + normalize-path: 3.0.0 + readable-stream: 4.7.0 + + archiver@7.0.1: + dependencies: + archiver-utils: 5.0.2 + async: 3.2.6 + buffer-crc32: 1.0.0 + readable-stream: 4.7.0 + readdir-glob: 1.1.3 + tar-stream: 3.2.0 + zip-stream: 6.0.1 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + argparse@1.0.10: dependencies: sprintf-js: 1.0.3 @@ -3874,6 +4552,10 @@ snapshots: array-union@2.1.0: {} + asn1@0.2.6: + dependencies: + safer-buffer: 2.1.2 + assertion-error@2.0.1: {} ast-v8-to-istanbul@1.0.5: @@ -3882,38 +4564,151 @@ snapshots: estree-walker: 3.0.3 js-tokens: 10.0.0 + async-lock@1.4.1: {} + + async@3.2.6: {} + + b4a@1.8.1: {} + + balanced-match@1.0.2: {} + + bare-events@2.9.1: {} + + bare-fs@4.7.4: + dependencies: + bare-events: 2.9.1 + bare-path: 3.1.1 + bare-stream: 2.13.3(bare-events@2.9.1) + bare-url: 2.4.6 + fast-fifo: 1.3.2 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + bare-path@3.1.1: {} + + bare-stream@2.13.3(bare-events@2.9.1): + dependencies: + b4a: 1.8.1 + streamx: 2.28.0 + teex: 1.0.1 + optionalDependencies: + bare-events: 2.9.1 + transitivePeerDependencies: + - react-native-b4a + + bare-url@2.4.6: + dependencies: + bare-path: 3.1.1 + + base64-js@1.5.1: {} + + bcrypt-pbkdf@1.0.2: + dependencies: + tweetnacl: 0.14.5 + before-after-hook@4.0.0: {} better-path-resolve@1.0.0: dependencies: is-windows: 1.0.2 + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + blake3-wasm@2.1.5: {} bottleneck@2.19.5: {} + brace-expansion@2.1.4: + dependencies: + balanced-match: 1.0.2 + braces@3.0.3: dependencies: fill-range: 7.1.1 + buffer-crc32@1.0.0: {} + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + buildcheck@0.0.7: + optional: true + + byline@5.0.0: {} + cac@6.7.14: {} chai@6.2.2: {} chardet@2.2.0: {} + chownr@1.1.4: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + compress-commons@6.0.2: + dependencies: + crc-32: 1.2.2 + crc32-stream: 6.0.0 + is-stream: 2.0.1 + normalize-path: 3.0.0 + readable-stream: 4.7.0 + content-type@2.0.0: {} convert-source-map@2.0.0: {} cookie@1.1.1: {} + core-util-is@1.0.3: {} + + cpu-features@0.0.10: + dependencies: + buildcheck: 0.0.7 + nan: 2.28.0 + optional: true + + crc-32@1.2.2: {} + + crc32-stream@6.0.0: + dependencies: + crc-32: 1.2.2 + readable-stream: 4.7.0 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 shebang-command: 2.0.0 which: 2.0.2 + debug@4.4.3(supports-color@10.2.2): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 10.2.2 + dequal@2.0.3: {} detect-indent@6.1.0: {} @@ -3924,8 +4719,42 @@ snapshots: dependencies: path-type: 4.0.0 + docker-compose@1.4.2: + dependencies: + yaml: 2.9.0 + + docker-modem@5.0.7(supports-color@10.2.2): + dependencies: + debug: 4.4.3(supports-color@10.2.2) + readable-stream: 3.6.2 + split-ca: 1.0.1 + ssh2: 1.17.0 + transitivePeerDependencies: + - supports-color + + dockerode@5.0.1(supports-color@10.2.2): + dependencies: + '@balena/dockerignore': 1.0.2 + '@grpc/grpc-js': 1.14.4 + '@grpc/proto-loader': 0.7.15 + docker-modem: 5.0.7(supports-color@10.2.2) + protobufjs: 7.6.5 + tar-fs: 2.1.5 + transitivePeerDependencies: + - supports-color + dom-accessibility-api@0.5.16: {} + eastasianwidth@0.2.0: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + enquirer@2.4.1: dependencies: ansi-colors: 4.1.3 @@ -3964,16 +4793,30 @@ snapshots: '@esbuild/win32-ia32': 0.28.1 '@esbuild/win32-x64': 0.28.1 + escalade@3.2.0: {} + esprima@4.0.1: {} estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 + event-target-shim@5.0.1: {} + + events-universal@1.0.1: + dependencies: + bare-events: 2.9.1 + transitivePeerDependencies: + - bare-abort-controller + + events@3.3.0: {} + expect-type@1.4.0: {} extendable-error@0.1.7: {} + fast-fifo@1.3.2: {} + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -4009,6 +4852,13 @@ snapshots: locate-path: 5.0.0 path-exists: 4.0.0 + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + fs-constants@1.0.0: {} + fs-extra@7.0.1: dependencies: graceful-fs: 4.2.11 @@ -4024,10 +4874,23 @@ snapshots: fsevents@2.3.3: optional: true + get-caller-file@2.0.5: {} + + get-port@5.1.1: {} + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + globby@11.1.0: dependencies: array-union: 2.1.0 @@ -4049,6 +4912,8 @@ snapshots: dependencies: safer-buffer: 2.1.2 + ieee754@1.2.1: {} + ignore@5.3.2: {} incur@0.4.25: @@ -4061,20 +4926,28 @@ snapshots: yaml: 2.9.0 zod: 4.4.3 + inherits@2.0.4: {} + is-extglob@2.1.1: {} + is-fullwidth-code-point@3.0.0: {} + is-glob@4.0.3: dependencies: is-extglob: 2.1.1 is-number@7.0.0: {} + is-stream@2.0.1: {} + is-subdir@1.2.0: dependencies: better-path-resolve: 1.0.0 is-windows@1.0.2: {} + isarray@1.0.0: {} + isexe@2.0.0: {} istanbul-lib-coverage@3.2.2: {} @@ -4090,6 +4963,12 @@ snapshots: html-escaper: 2.0.2 istanbul-lib-report: 3.0.1 + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + js-tokens@10.0.0: {} js-tokens@4.0.0: {} @@ -4111,6 +4990,10 @@ snapshots: kleur@4.1.5: {} + lazystream@1.0.1: + dependencies: + readable-stream: 2.3.8 + lightningcss-android-arm64@1.33.0: optional: true @@ -4164,8 +5047,16 @@ snapshots: dependencies: p-locate: 4.1.0 + lodash.camelcase@4.3.0: {} + lodash.startcase@4.4.0: {} + lodash@4.18.1: {} + + long@5.3.2: {} + + lru-cache@10.4.3: {} + lz-string@1.5.0: {} magic-string@0.30.21: @@ -4201,12 +5092,33 @@ snapshots: - bufferutil - utf-8-validate + minimatch@5.1.9: + dependencies: + brace-expansion: 2.1.4 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.4 + + minipass@7.1.3: {} + + mkdirp-classic@0.5.3: {} + + mkdirp@3.0.1: {} + mri@1.2.0: {} mrmime@2.0.1: {} + ms@2.1.3: {} + + nan@2.28.0: + optional: true + nanoid@3.3.16: {} + normalize-path@3.0.0: {} + obug@2.1.4: {} octokit@5.0.5: @@ -4223,6 +5135,10 @@ snapshots: '@octokit/types': 16.0.0 '@octokit/webhooks': 14.2.0 + once@1.4.0: + dependencies: + wrappy: 1.0.2 + outdent@0.5.0: {} oxfmt@0.51.0: @@ -4323,6 +5239,8 @@ snapshots: p-try@2.2.0: {} + package-json-from-dist@1.0.1: {} + package-manager-detector@0.2.11: dependencies: quansync: 0.2.11 @@ -4331,6 +5249,11 @@ snapshots: path-key@3.1.1: {} + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + path-to-regexp@6.3.0: {} path-type@4.0.0: {} @@ -4406,6 +5329,42 @@ snapshots: ansi-styles: 5.2.0 react-is: 17.0.2 + process-nextick-args@2.0.1: {} + + process@0.11.10: {} + + proper-lockfile@4.1.2: + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + + properties-reader@3.0.1(supports-color@10.2.2): + dependencies: + '@kwsites/file-exists': 1.1.1(supports-color@10.2.2) + mkdirp: 3.0.1 + transitivePeerDependencies: + - supports-color + + protobufjs@7.6.5: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.5 + '@protobufjs/eventemitter': 1.1.1 + '@protobufjs/fetch': 1.1.1 + '@protobufjs/float': 1.0.2 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.2 + '@types/node': 26.1.1 + long: 5.3.2 + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + quansync@0.2.11: {} queue-microtask@1.2.3: {} @@ -4419,8 +5378,40 @@ snapshots: pify: 4.0.1 strip-bom: 3.0.0 + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readable-stream@4.7.0: + dependencies: + abort-controller: 3.0.0 + buffer: 6.0.3 + events: 3.3.0 + process: 0.11.10 + string_decoder: 1.3.0 + + readdir-glob@1.1.3: + dependencies: + minimatch: 5.1.9 + + require-directory@2.1.1: {} + resolve-from@5.0.0: {} + retry@0.12.0: {} + reusify@1.1.0: {} rolldown@1.1.5: @@ -4448,6 +5439,10 @@ snapshots: dependencies: queue-microtask: 1.2.3 + safe-buffer@5.1.2: {} + + safe-buffer@5.2.1: {} + safer-buffer@2.1.2: {} semver@7.8.5: {} @@ -4492,6 +5487,8 @@ snapshots: siginfo@2.0.0: {} + signal-exit@3.0.7: {} + signal-exit@4.1.0: {} sirv@3.0.2: @@ -4511,18 +5508,66 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + split-ca@1.0.1: {} + split2@4.2.0: {} sprintf-js@1.0.3: {} + ssh-remote-port-forward@1.0.4: + dependencies: + '@types/ssh2': 0.5.52 + ssh2: 1.17.0 + + ssh2@1.17.0: + dependencies: + asn1: 0.2.6 + bcrypt-pbkdf: 1.0.2 + optionalDependencies: + cpu-features: 0.0.10 + nan: 2.28.0 + stackback@0.0.2: {} std-env@4.2.0: {} + streamx@2.28.0: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + strip-bom@3.0.0: {} supports-color@10.2.2: {} @@ -4531,8 +5576,82 @@ snapshots: dependencies: has-flag: 4.0.0 + tar-fs@2.1.5: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.4 + tar-stream: 2.2.0 + + tar-fs@3.1.3: + dependencies: + pump: 3.0.4 + tar-stream: 3.2.0 + optionalDependencies: + bare-fs: 4.7.4 + bare-path: 3.1.1 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + + tar-stream@3.2.0: + dependencies: + b4a: 1.8.1 + bare-fs: 4.7.4 + fast-fifo: 1.3.2 + streamx: 2.28.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + teex@1.0.1: + dependencies: + streamx: 2.28.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + term-size@2.2.1: {} + testcontainers@12.0.4(supports-color@10.2.2): + dependencies: + '@balena/dockerignore': 1.0.2 + '@types/dockerode': 4.0.1 + archiver: 7.0.1 + async-lock: 1.4.1 + byline: 5.0.0 + debug: 4.4.3(supports-color@10.2.2) + docker-compose: 1.4.2 + dockerode: 5.0.1(supports-color@10.2.2) + get-port: 5.1.1 + proper-lockfile: 4.1.2 + properties-reader: 3.0.1(supports-color@10.2.2) + ssh-remote-port-forward: 1.0.4 + tar-fs: 3.1.3 + tmp: 0.2.7 + undici: 8.9.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + - supports-color + + text-decoder@1.2.7: + dependencies: + b4a: 1.8.1 + transitivePeerDependencies: + - react-native-b4a + tinybench@2.9.0: {} tinyexec@1.2.4: {} @@ -4546,6 +5665,8 @@ snapshots: tinyrainbow@3.1.0: {} + tmp@0.2.7: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -4569,6 +5690,8 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tweetnacl@0.14.5: {} + typescript@7.0.2: optionalDependencies: '@typescript/typescript-aix-ppc64': 7.0.2 @@ -4592,10 +5715,14 @@ snapshots: '@typescript/typescript-win32-arm64': 7.0.2 '@typescript/typescript-win32-x64': 7.0.2 + undici-types@5.26.5: {} + undici-types@8.3.0: {} undici@7.28.0: {} + undici@8.9.0: {} + unenv@2.0.0-rc.24: dependencies: pathe: 2.0.3 @@ -4606,6 +5733,8 @@ snapshots: universalify@0.1.2: {} + util-deprecate@1.0.2: {} + vite-plus@0.2.6(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(esbuild@0.28.1)(tsx@4.23.1)(typescript@7.0.2)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0))(yaml@2.9.0): dependencies: '@oxc-project/types': 0.141.0 @@ -4741,14 +5870,42 @@ snapshots: - bufferutil - utf-8-validate + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + + wrappy@1.0.2: {} + ws@8.21.0: {} ws@8.21.1: {} xtend@4.0.2: {} + y18n@5.0.8: {} + yaml@2.9.0: {} + yargs-parser@21.1.1: {} + + yargs@17.7.3: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + youch-core@0.3.3: dependencies: '@poppinss/exception': 1.2.3 @@ -4805,4 +5962,10 @@ snapshots: transitivePeerDependencies: - svelte + zip-stream@6.0.1: + dependencies: + archiver-utils: 5.0.2 + compress-commons: 6.0.2 + readable-stream: 4.7.0 + zod@4.4.3: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0ad214d..f18acd6 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,10 +7,13 @@ catalog: '@cloudflare/workers-types': ^4.20260701.0 '@clack/prompts': ^1.7.0 '@octokit/rest': ^22.0.1 + '@testcontainers/postgresql': ^12.0.4 '@types/node': latest + '@types/pg': ^8.20.3 '@vitest/coverage-v8': latest incur: 0.4.25 octokit: ^5.0.5 + pg: ^8.22.0 tsx: ^4.23.1 typescript: latest vite-plus: latest @@ -19,7 +22,10 @@ catalog: zile: latest allowBuilds: + cpu-features: false esbuild: true + protobufjs: false + ssh2: false workerd: true minimumReleaseAgeExclude: diff --git a/src/Store.postgres.test.ts b/src/Store.postgres.test.ts index c7cdce7..bfdc2d1 100644 --- a/src/Store.postgres.test.ts +++ b/src/Store.postgres.test.ts @@ -1,5 +1,5 @@ -import { fakePostgresClient } from '../test/postgres.js' import { storeContract } from '../test/storeContract.js' +import { testPostgres } from '../test/postgres.js' import * as Entry from './Entry.js' import * as Frog from './Frog.js' import * as Store from './Store.js' @@ -11,34 +11,44 @@ const friction = { title: 'Tool result omitted its state', } as const +const postgres = testPostgres() + describe('postgres', () => { - test('behavior: migration is explicit, namespaced, and idempotent SQL', async () => { - const client = fakePostgresClient() + test('behavior: migration creates the configured schema and is idempotent', async () => { + const client = postgres.client() const store = Store.postgres(client, { namespace: 'consumer-a', schema: 'frog' }) - + const table = async () => + client.query<{ table_name: string }>( + `SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'frog' AND table_name = 'frog_entries'`, + ) + + await expect(table()).resolves.toMatchObject({ rows: [] }) + await store.migrate() await store.migrate() - expect(client.queries).toHaveLength(2) - expect(client.queries[0]).toBe('CREATE SCHEMA IF NOT EXISTS "frog"') - expect(client.queries[1]).toContain('CREATE TABLE IF NOT EXISTS "frog"."frog_entries"') - expect(client.queries[1]).toContain('UNIQUE (namespace, dedupe_key)') + await expect(table()).resolves.toMatchObject({ rows: [{ table_name: 'frog_entries' }] }) }) test('behavior: an omitted schema follows the client search path', async () => { - const client = fakePostgresClient() - const store = Store.postgres(client, { namespace: 'consumer-a' }) + const client = postgres.client() + const store = Store.postgres(client, { namespace: 'search-path' }) const frog = Frog.create({ store }) await store.migrate() await frog.log(friction) - expect(client.queries).toHaveLength(2) - expect(client.queries[0]).toContain('CREATE TABLE IF NOT EXISTS "frog_entries"') - expect(client.queries[1]).toContain('INSERT INTO "frog_entries"') + const result = await client.query<{ table_schema: string }>( + `SELECT table_schema + FROM information_schema.tables + WHERE table_schema = current_schema() AND table_name = 'frog_entries'`, + ) + expect(result.rows).toEqual([{ table_schema: 'public' }]) }) test('behavior: logs, deduplicates, updates, lists, and removes', async () => { - const store = Store.postgres(fakePostgresClient(), { namespace: 'consumer-a' }) + const store = await postgres.store() const frog = Frog.create({ store }) const first = await frog.log(friction) @@ -59,7 +69,7 @@ describe('postgres', () => { }) test('behavior: updating a logged title moves its deduplication identity', async () => { - const store = Store.postgres(fakePostgresClient(), { namespace: 'consumer-a' }) + const store = await postgres.store() const frog = Frog.create({ store }) const first = await frog.log(friction) @@ -71,10 +81,11 @@ describe('postgres', () => { }) test('behavior: namespaces isolate consumers and force preserves intentional duplicates', async () => { - const client = fakePostgresClient() + const client = postgres.client() const first = Frog.create({ store: Store.postgres(client, { namespace: 'one' }) }) const second = Frog.create({ store: Store.postgres(client, { namespace: 'two' }) }) + await first.store.migrate() await first.log(friction) await first.log(friction, { force: true }) await second.log(friction) @@ -83,17 +94,17 @@ describe('postgres', () => { }) test('behavior: removal uses returned rows when the client omits rowCount', async () => { - const backing = fakePostgresClient() const client: Store.postgres.Client = { async query = Record>( text: string, values?: unknown[], ): Promise<{ rows: T[] }> { - const result = await backing.query(text, values) + const result = await postgres.client().query(text, values) return { rows: result.rows } }, } - const store = Store.postgres(client, { namespace: 'consumer-a' }) + const store = Store.postgres(client, { namespace: 'remove-without-row-count' }) + await store.migrate() const written = await store.write(friction) await expect(store.remove(written.id)).resolves.toBe(true) @@ -102,7 +113,7 @@ describe('postgres', () => { test('error: rejects unsafe schema names before issuing SQL', () => { expect(() => - Store.postgres(fakePostgresClient(), { + Store.postgres(postgres.client(), { namespace: 'one', schema: 'public; DROP TABLE users', }), @@ -115,6 +126,4 @@ describe('postgres', () => { }) }) -storeContract('Postgres', async () => - Store.postgres(fakePostgresClient(), { namespace: 'contract' }), -) +storeContract('Postgres', () => postgres.store()) diff --git a/src/cli/commands/list.test.ts b/src/cli/commands/list.test.ts index a2b2da7..187a6ab 100644 --- a/src/cli/commands/list.test.ts +++ b/src/cli/commands/list.test.ts @@ -1,7 +1,5 @@ import * as cli from '../../../test/cli.js' import * as helpers from '../../../test/helpers.js' -import { fakePostgresClient } from '../../../test/postgres.js' -import * as Frog from '../../Frog.js' import * as Store from '../../Store.js' const body = 'The filter was swallowed.' @@ -78,10 +76,16 @@ test('behavior: an empty directory lists nothing', async () => { }) test('behavior: a durable store lists occurrence counts', async () => { - const store = Store.postgres(fakePostgresClient(), { namespace: 'list-test' }) - const frog = Frog.create({ store }) - await frog.log({ body, severity: 'minor', title: 'Repeated friction' }) - await frog.log({ body, severity: 'minor', title: 'repeated friction' }) + const entry = { body, id: 'one', severity: 'minor', title: 'Repeated friction' } as const + const store = Store.from({ + name: 'durable', + tracksOccurrences: true, + read: async () => [entry], + records: async () => [{ entry, occurrences: 2 }], + get: async () => entry, + write: async () => ({ id: entry.id, location: entry.id }), + remove: async () => false, + }) expect(await cli.data(['list', '--cwd', await helpers.repo()], {}, { store })).toMatchObject({ entries: [{ occurrences: 2, title: 'Repeated friction' }], diff --git a/src/cli/commands/log.test.ts b/src/cli/commands/log.test.ts index 34f0205..e4ec9eb 100644 --- a/src/cli/commands/log.test.ts +++ b/src/cli/commands/log.test.ts @@ -5,15 +5,16 @@ import path from 'node:path' import * as cli from '../../../test/cli.js' import { github } from '../../../test/github.js' import * as helpers from '../../../test/helpers.js' -import { fakePostgresClient } from '../../../test/postgres.js' +import { testPostgres } from '../../../test/postgres.js' import * as Config from '../../Config.js' import * as Store from '../../Store.js' const title = '`pnpm test -- ` ignores file filters' const body = '## Description\n\nThe filter was swallowed.' +const postgres = testPostgres() test('error: immediate publishing requires the file store', async () => { - const store = Store.postgres(fakePostgresClient(), { namespace: 'log-test' }) + const store = await postgres.store() const cwd = await helpers.repo() expect( @@ -23,7 +24,7 @@ test('error: immediate publishing requires the file store', async () => { }) test('behavior: durable-store follow-up does not suggest repository publishing', async () => { - const store = Store.postgres(fakePostgresClient(), { namespace: 'log-test' }) + const store = await postgres.store() const cwd = await helpers.repo() expect( @@ -35,7 +36,7 @@ test('behavior: durable-store follow-up does not suggest repository publishing', }) test('behavior: durable-store logging atomically records repeated titles', async () => { - const store = Store.postgres(fakePostgresClient(), { namespace: 'log-test' }) + const store = await postgres.store() const cwd = await helpers.repo() const first = await cli.data(['log', title, '--body', body, '--cwd', cwd], {}, { store }) diff --git a/src/cli/commands/publish.test.ts b/src/cli/commands/publish.test.ts index baa99b2..4f65110 100644 --- a/src/cli/commands/publish.test.ts +++ b/src/cli/commands/publish.test.ts @@ -3,7 +3,6 @@ import path from 'node:path' import * as cli from '../../../test/cli.js' import { github } from '../../../test/github.js' import * as helpers from '../../../test/helpers.js' -import { fakePostgresClient } from '../../../test/postgres.js' import * as Config from '../../Config.js' import * as Github from '../../Github.js' import * as Store from '../../Store.js' @@ -25,7 +24,15 @@ function env(url: string): Record { } test('error: repository publishing requires the file store', async () => { - const store = Store.postgres(fakePostgresClient(), { namespace: 'publish-test' }) + const store = Store.from({ + name: 'remote', + read: async () => [], + get: async () => { + throw new Error('unused') + }, + write: async () => ({ id: 'unused', location: 'unused' }), + remove: async () => false, + }) const cwd = await helpers.repo({ remote }) expect((await cli.error(['publish', '--cwd', cwd], {}, { store })).code).toBe( diff --git a/src/cli/commands/sync.test.ts b/src/cli/commands/sync.test.ts index 48aacb1..3ba26ea 100644 --- a/src/cli/commands/sync.test.ts +++ b/src/cli/commands/sync.test.ts @@ -3,7 +3,6 @@ import path from 'node:path' import * as cli from '../../../test/cli.js' import { github } from '../../../test/github.js' import * as helpers from '../../../test/helpers.js' -import { fakePostgresClient } from '../../../test/postgres.js' import * as AppSync from '../../AppSync.js' import * as Entry from '../../Entry.js' import * as Github from '../../Github.js' @@ -28,7 +27,15 @@ function env(url: string): Record { } test('error: repository reconciliation requires the file store', async () => { - const store = Store.postgres(fakePostgresClient(), { namespace: 'sync-test' }) + const store = Store.from({ + name: 'remote', + read: async () => [], + get: async () => { + throw new Error('unused') + }, + write: async () => ({ id: 'unused', location: 'unused' }), + remove: async () => false, + }) const cwd = await helpers.repo({ remote }) expect((await cli.error(['sync', '--cwd', cwd], {}, { store })).code).toBe( diff --git a/test/postgres.ts b/test/postgres.ts index 81b03d3..4ad7532 100644 --- a/test/postgres.ts +++ b/test/postgres.ts @@ -1,114 +1,60 @@ -import type * as Store from '../src/Store.js' +import { randomUUID } from 'node:crypto' +import { PostgreSqlContainer, type StartedPostgreSqlContainer } from '@testcontainers/postgresql' +import { Pool } from 'pg' +import * as Store from '../src/Store.js' -type Stored = { contents: string; dedupeKey: string; id: string; occurrences: number } +const image = 'postgres:18-alpine' -/** Small behavioral Postgres client used by store and contract tests. */ -export type FakePostgresClient = Store.postgres.Client & { - /** SQL statements issued through the client. */ - readonly queries: string[] - /** Rows persisted by the fake client. */ - readonly rows: Map -} +/** Starts one isolated Postgres container for the importing test file. */ +export function testPostgres(): testPostgres.ReturnType { + let container: StartedPostgreSqlContainer | undefined + let client: Pool | undefined -/** Creates a behavioral Postgres client for store and contract tests. */ -export function fakePostgresClient(): FakePostgresClient { - const queries: string[] = [] - const rows = new Map() - return { - queries, - rows, - async query = Record>( - text: string, - values: unknown[] = [], - ): Promise<{ rowCount: number; rows: T[] }> { - queries.push(text) - if (text.startsWith('CREATE ')) return { rowCount: 0, rows: [] } + beforeAll(async () => { + container = await new PostgreSqlContainer(image).start() + client = new Pool({ connectionString: container.getConnectionUri() }) + }, 120_000) + + afterAll(async () => { + try { + await client?.end() + } finally { + await container?.stop() + } + }, 120_000) - const namespace = String(values[0]) - const key = (id: string) => `${namespace}\u0000${id}` - if (text.includes('ON CONFLICT(namespace, dedupe_key)')) { - const [, rawId, rawDedupe, rawContents] = values - const id = String(rawId) - const dedupeKey = String(rawDedupe) - const existing = [...rows.entries()].find( - ([storedKey, row]) => - storedKey.startsWith(`${namespace}\u0000`) && row.dedupeKey === dedupeKey, - )?.[1] - if (existing) { - existing.occurrences += 1 - return { - rowCount: 1, - rows: [ - { - contents: existing.contents, - created: false, - id: existing.id, - occurrence_count: existing.occurrences, - } as unknown as T, - ], - } - } - const stored = { contents: String(rawContents), dedupeKey, id, occurrences: 1 } - rows.set(key(id), stored) - return { - rowCount: 1, - rows: [ - { contents: stored.contents, created: true, id, occurrence_count: 1 } as unknown as T, - ], - } - } - if (text.startsWith('INSERT INTO')) { - const [, rawId, rawDedupe, rawContents, rawTitleDedupe] = values - const id = String(rawId) - const previous = rows.get(key(id)) - rows.set(key(id), { - contents: String(rawContents), - dedupeKey: - previous?.dedupeKey.startsWith('title:') && typeof rawTitleDedupe === 'string' - ? rawTitleDedupe - : (previous?.dedupeKey ?? String(rawDedupe)), - id, - occurrences: previous?.occurrences ?? 1, - }) - return { rowCount: 1, rows: [] } - } - if (text.startsWith('SELECT id, contents')) { - const selected = - typeof values[1] === 'string' - ? [rows.get(key(values[1]))].filter(Boolean) - : [...rows.entries()] - .filter(([storedKey]) => storedKey.startsWith(`${namespace}\u0000`)) - .map(([, row]) => row) - return { - rowCount: selected.length, - rows: selected.map( - (row) => - ({ - contents: row!.contents, - id: row!.id, - occurrence_count: row!.occurrences, - }) as unknown as T, - ), - } - } - if (text.startsWith('SELECT id FROM')) { - const selected = [...rows.entries()].filter(([storedKey]) => - storedKey.startsWith(`${namespace}\u0000`), - ) - return { - rowCount: selected.length, - rows: selected.map(([, row]) => ({ id: row.id }) as unknown as T), - } - } - if (text.startsWith('DELETE FROM')) { - const id = String(values[1]) - const removed = rows.delete(key(id)) - return { - rowCount: removed ? 1 : 0, - rows: removed ? ([{ id }] as unknown as T[]) : [], - } - } - throw new Error(`Unhandled SQL: ${text}`) + const getClient = () => { + if (!client) throw new Error('Postgres test container has not started.') + return client + } + + return { + client: getClient, + async store(options = {}) { + const store = Store.postgres(getClient(), { + namespace: options.namespace ?? randomUUID(), + ...(options.schema ? { schema: options.schema } : {}), + }) + await store.migrate() + return store }, } } + +export declare namespace testPostgres { + /** Options for constructing an isolated store inside the test database. */ + type Options = { + /** Namespace for a test that needs to coordinate multiple stores. */ + namespace?: string | undefined + /** Optional schema for the store table. */ + schema?: string | undefined + } + + /** Container-backed Postgres test fixture. */ + type ReturnType = { + /** Returns the connected pool after the test hook starts the container. */ + readonly client: () => Pool + /** Creates and migrates a store with an isolated namespace. */ + readonly store: (options?: Options) => Promise + } +} From 1c18c67316ca8b87a13d228bc05c687a2b4018ae Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Tue, 4 Aug 2026 13:01:22 +1000 Subject: [PATCH 11/26] refactor: group postgres options --- .changeset/calm-frogs-store.md | 2 +- README.md | 2 +- src/Store.postgres.test.ts | 13 +++++++------ src/Store.test-d.ts | 4 +++- src/Store.ts | 7 +++++-- src/cli/internal/store.ts | 3 ++- test/postgres.ts | 3 ++- 7 files changed, 21 insertions(+), 13 deletions(-) diff --git a/.changeset/calm-frogs-store.md b/.changeset/calm-frogs-store.md index e683810..cbf0723 100644 --- a/.changeset/calm-frogs-store.md +++ b/.changeset/calm-frogs-store.md @@ -9,7 +9,7 @@ import { Frog, Store } from 'frog' import { Pool } from 'pg' const client = new Pool({ connectionString: process.env.DATABASE_URL }) -const store = Store.postgres(client, { namespace: 'support-agent' }) +const store = Store.postgres({ client, namespace: 'support-agent' }) await store.migrate() const frog = Frog.create({ store }) await frog.log({ diff --git a/README.md b/README.md index 9285cc2..2568e29 100644 --- a/README.md +++ b/README.md @@ -202,7 +202,7 @@ import { Frog, Store } from 'frog' import { Pool } from 'pg' const client = new Pool({ connectionString: process.env.DATABASE_URL }) -const store = Store.postgres(client, { namespace: 'support-agent' }) +const store = Store.postgres({ client, namespace: 'support-agent' }) await store.migrate() const frog = Frog.create({ store }) diff --git a/src/Store.postgres.test.ts b/src/Store.postgres.test.ts index bfdc2d1..0b471ae 100644 --- a/src/Store.postgres.test.ts +++ b/src/Store.postgres.test.ts @@ -16,7 +16,7 @@ const postgres = testPostgres() describe('postgres', () => { test('behavior: migration creates the configured schema and is idempotent', async () => { const client = postgres.client() - const store = Store.postgres(client, { namespace: 'consumer-a', schema: 'frog' }) + const store = Store.postgres({ client, namespace: 'consumer-a', schema: 'frog' }) const table = async () => client.query<{ table_name: string }>( `SELECT table_name @@ -33,7 +33,7 @@ describe('postgres', () => { test('behavior: an omitted schema follows the client search path', async () => { const client = postgres.client() - const store = Store.postgres(client, { namespace: 'search-path' }) + const store = Store.postgres({ client, namespace: 'search-path' }) const frog = Frog.create({ store }) await store.migrate() @@ -82,8 +82,8 @@ describe('postgres', () => { test('behavior: namespaces isolate consumers and force preserves intentional duplicates', async () => { const client = postgres.client() - const first = Frog.create({ store: Store.postgres(client, { namespace: 'one' }) }) - const second = Frog.create({ store: Store.postgres(client, { namespace: 'two' }) }) + const first = Frog.create({ store: Store.postgres({ client, namespace: 'one' }) }) + const second = Frog.create({ store: Store.postgres({ client, namespace: 'two' }) }) await first.store.migrate() await first.log(friction) @@ -103,7 +103,7 @@ describe('postgres', () => { return { rows: result.rows } }, } - const store = Store.postgres(client, { namespace: 'remove-without-row-count' }) + const store = Store.postgres({ client, namespace: 'remove-without-row-count' }) await store.migrate() const written = await store.write(friction) @@ -113,7 +113,8 @@ describe('postgres', () => { test('error: rejects unsafe schema names before issuing SQL', () => { expect(() => - Store.postgres(postgres.client(), { + Store.postgres({ + client: postgres.client(), namespace: 'one', schema: 'public; DROP TABLE users', }), diff --git a/src/Store.test-d.ts b/src/Store.test-d.ts index c4becbb..7e5515b 100644 --- a/src/Store.test-d.ts +++ b/src/Store.test-d.ts @@ -5,4 +5,6 @@ declare const value: Store.from.Value expectTypeOf(Store.from(value)).toEqualTypeOf() expectTypeOf(Store.file({ root: '/repo' })).toEqualTypeOf() -expectTypeOf(Store.postgres(client, { namespace: 'agent' })).toEqualTypeOf() +expectTypeOf(Store.postgres({ client, namespace: 'agent' })).toEqualTypeOf() +// @ts-expect-error Postgres configuration is supplied through one options object. +Store.postgres(client, { namespace: 'agent' }) diff --git a/src/Store.ts b/src/Store.ts index 7403378..15fc87d 100644 --- a/src/Store.ts +++ b/src/Store.ts @@ -383,8 +383,9 @@ type PostgresRow = { occurrence_count: number | string } -/** Creates a Postgres-backed store from a `pg`-compatible client. */ -export function postgres(client: postgres.Client, options: postgres.Options): Store { +/** Creates a Postgres-backed store from a `pg`-compatible client and namespace. */ +export function postgres(options: postgres.Options): Store { + const { client } = options const namespace = required(options.namespace, 'namespace') const table = tableName(options.schema) @@ -512,6 +513,8 @@ export declare namespace postgres { /** Postgres store configuration. */ type Options = { + /** `pg`-compatible pool or transaction client. */ + client: Client /** Isolates independent consumers sharing one table. */ namespace: string /** Optional PostgreSQL schema. Omit it to use the client's current search path. */ diff --git a/src/cli/internal/store.ts b/src/cli/internal/store.ts index 8b13074..4014272 100644 --- a/src/cli/internal/store.ts +++ b/src/cli/internal/store.ts @@ -51,7 +51,8 @@ export async function resolve(env: Environment): Promise } const client = new Pool({ connectionString: selected.connectionString }) return { - store: Store.postgres(client, { + store: Store.postgres({ + client, namespace: selected.namespace, ...(selected.schema ? { schema: selected.schema } : {}), }), diff --git a/test/postgres.ts b/test/postgres.ts index 4ad7532..0fc356a 100644 --- a/test/postgres.ts +++ b/test/postgres.ts @@ -31,7 +31,8 @@ export function testPostgres(): testPostgres.ReturnType { return { client: getClient, async store(options = {}) { - const store = Store.postgres(getClient(), { + const store = Store.postgres({ + client: getClient(), namespace: options.namespace ?? randomUUID(), ...(options.schema ? { schema: options.schema } : {}), }) From 69ce3b2bde5657a9b819b4a6c4c99b51ba7a9b92 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Tue, 4 Aug 2026 13:06:06 +1000 Subject: [PATCH 12/26] test: namespace postgres fixture --- src/Store.postgres.test.ts | 4 ++-- src/cli/commands/log.test.ts | 4 ++-- test/postgres.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Store.postgres.test.ts b/src/Store.postgres.test.ts index 0b471ae..0649518 100644 --- a/src/Store.postgres.test.ts +++ b/src/Store.postgres.test.ts @@ -1,5 +1,5 @@ import { storeContract } from '../test/storeContract.js' -import { testPostgres } from '../test/postgres.js' +import * as Postgres from '../test/postgres.js' import * as Entry from './Entry.js' import * as Frog from './Frog.js' import * as Store from './Store.js' @@ -11,7 +11,7 @@ const friction = { title: 'Tool result omitted its state', } as const -const postgres = testPostgres() +const postgres = Postgres.get() describe('postgres', () => { test('behavior: migration creates the configured schema and is idempotent', async () => { diff --git a/src/cli/commands/log.test.ts b/src/cli/commands/log.test.ts index e4ec9eb..98a4e56 100644 --- a/src/cli/commands/log.test.ts +++ b/src/cli/commands/log.test.ts @@ -5,13 +5,13 @@ import path from 'node:path' import * as cli from '../../../test/cli.js' import { github } from '../../../test/github.js' import * as helpers from '../../../test/helpers.js' -import { testPostgres } from '../../../test/postgres.js' +import * as Postgres from '../../../test/postgres.js' import * as Config from '../../Config.js' import * as Store from '../../Store.js' const title = '`pnpm test -- ` ignores file filters' const body = '## Description\n\nThe filter was swallowed.' -const postgres = testPostgres() +const postgres = Postgres.get() test('error: immediate publishing requires the file store', async () => { const store = await postgres.store() diff --git a/test/postgres.ts b/test/postgres.ts index 0fc356a..64b7d46 100644 --- a/test/postgres.ts +++ b/test/postgres.ts @@ -6,7 +6,7 @@ import * as Store from '../src/Store.js' const image = 'postgres:18-alpine' /** Starts one isolated Postgres container for the importing test file. */ -export function testPostgres(): testPostgres.ReturnType { +export function get(): get.ReturnType { let container: StartedPostgreSqlContainer | undefined let client: Pool | undefined @@ -42,7 +42,7 @@ export function testPostgres(): testPostgres.ReturnType { } } -export declare namespace testPostgres { +export declare namespace get { /** Options for constructing an isolated store inside the test database. */ type Options = { /** Namespace for a test that needs to coordinate multiple stores. */ From 29c402a23e20148681a91eac8cd0804071f5b166 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Tue, 4 Aug 2026 13:27:54 +1000 Subject: [PATCH 13/26] fix: resolve store review feedback --- src/Store.test.ts | 2 ++ src/Store.ts | 1 + src/cli/Cli.test.ts | 51 ++++++++++++++++++++++++++++++++ src/cli/Cli.ts | 32 +++++++++++++++----- src/cli/commands/log.test.ts | 1 + src/cli/commands/log.ts | 6 ++-- src/cli/commands/resolve.test.ts | 20 +++++++++++++ src/cli/commands/resolve.ts | 2 +- 8 files changed, 104 insertions(+), 11 deletions(-) diff --git a/src/Store.test.ts b/src/Store.test.ts index 9fa688b..85c1529 100644 --- a/src/Store.test.ts +++ b/src/Store.test.ts @@ -231,6 +231,8 @@ describe('isId', () => { ['', false], ['.hidden', false], ['..', false], + ['entry.', false], + ['entry ', false], ['../outside', false], ['nested/outside', false], ['nested\\outside', false], diff --git a/src/Store.ts b/src/Store.ts index 15fc87d..e2edb1a 100644 --- a/src/Store.ts +++ b/src/Store.ts @@ -166,6 +166,7 @@ export function isId(id: string): boolean { return ( id.length > 0 && !id.startsWith('.') && + !/[. ]$/.test(id) && !id.includes('/') && !id.includes('\\') && !id.includes('\0') diff --git a/src/cli/Cli.test.ts b/src/cli/Cli.test.ts index e3ba0bc..98b1e55 100644 --- a/src/cli/Cli.test.ts +++ b/src/cli/Cli.test.ts @@ -1,3 +1,7 @@ +import * as childProcess from 'node:child_process' +import * as fs from 'node:fs/promises' +import * as path from 'node:path' +import * as helpers from '../../test/helpers.js' import { serve } from './Cli.js' test.each([['--version'], ['list', '--help'], ['--schema']])( @@ -16,3 +20,50 @@ test.each([['--version'], ['list', '--help'], ['--schema']])( expect(output).toBeTruthy() }, ) + +test('error: store setup failures use the requested output format', async () => { + const root = await helpers.tmpdir() + const packageRoot = path.join(root, 'frog') + await fs.cp(path.resolve(import.meta.dirname, '..'), path.join(packageRoot, 'src'), { + recursive: true, + }) + await fs.writeFile(path.join(packageRoot, 'package.json'), '{"type":"module"}\n') + for (const dependency of ['@clack/prompts', '@octokit/rest', 'incur', 'yaml']) { + const link = path.join(packageRoot, 'node_modules', dependency) + await fs.mkdir(path.dirname(link), { recursive: true }) + await fs.symlink(path.resolve(import.meta.dirname, '../../node_modules', dependency), link) + } + + const result = await new Promise<{ code: number | null; stderr: string; stdout: string }>( + (resolve, reject) => { + const child = childProcess.spawn( + process.execPath, + [ + '--import', + import.meta.resolve('tsx'), + path.join(packageRoot, 'src/bin.ts'), + 'list', + '--format', + 'json', + ], + { + cwd: packageRoot, + env: { ...process.env, FROG_DATABASE_URL: 'postgres://driver-must-not-load' }, + }, + ) + let stderr = '' + let stdout = '' + child.stderr.on('data', (chunk) => (stderr += chunk)) + child.stdout.on('data', (chunk) => (stdout += chunk)) + child.on('error', reject) + child.on('close', (code) => resolve({ code, stderr, stdout })) + }, + ) + + expect(result.code).toBe(1) + expect(result.stderr).toBe('') + expect(JSON.parse(result.stdout)).toEqual({ + code: 'UNKNOWN', + message: 'The Postgres CLI store requires the optional `pg` package.', + }) +}) diff --git a/src/cli/Cli.ts b/src/cli/Cli.ts index e70bfc5..d841fab 100644 --- a/src/cli/Cli.ts +++ b/src/cli/Cli.ts @@ -32,6 +32,10 @@ const envSchema = z.object({ /** Creates the CLI with one optional store for commands that persist friction. */ export function create(options: create.Options = {}) { + return createCli(options) +} + +function createCli(options: createCli.Options = {}) { return Cli.create('frog', { description: 'Automated friction logging for agents.', env: envSchema, @@ -47,7 +51,8 @@ export function create(options: create.Options = {}) { }) .use( middleware(async (context, next) => { - if (options.store) context.set('store', options.store) + const store = options.store ?? (await options.resolveStore?.()) + if (store) context.set('store', store) await next() }), ) @@ -61,6 +66,12 @@ export function create(options: create.Options = {}) { .command(targets) } +declare namespace createCli { + type Options = create.Options & { + resolveStore?: (() => Promise) | undefined + } +} + export declare namespace create { type Options = { /** Store injected into commands that persist friction. The file store is derived per command by default. */ @@ -73,12 +84,19 @@ export const cli = create() /** Serves init with the project runner when one is detected. */ export async function serve(argv: string[] = process.argv.slice(2), options: serve.Options = {}) { const { store, ...serveOptions } = options - const selected = - store === undefined && usesStore(argv) - ? await environmentStore.resolve(options.env ?? process.env) - : undefined - const commandStore = store ?? selected?.store - const runnerCli = create(commandStore ? { store: commandStore } : {}) + let selected: environmentStore.Selection | undefined + let selecting: Promise | undefined + const runnerCli = createCli({ + ...(store ? { store } : {}), + ...(store === undefined && usesStore(argv) + ? { + resolveStore: async () => { + selected = await (selecting ??= environmentStore.resolve(options.env ?? process.env)) + return selected?.store + }, + } + : {}), + }) const run = async () => { if (command(argv) !== 'init') return runnerCli.serve(argv, serveOptions) diff --git a/src/cli/commands/log.test.ts b/src/cli/commands/log.test.ts index 98a4e56..e586852 100644 --- a/src/cli/commands/log.test.ts +++ b/src/cli/commands/log.test.ts @@ -47,6 +47,7 @@ test('behavior: durable-store logging atomically records repeated titles', async ) expect(repeated.id).toBe(first.id) + expect(repeated.title).toBe(title) expect(await store.records()).toMatchObject([{ occurrences: 2 }]) }) const ownForm = [ diff --git a/src/cli/commands/log.ts b/src/cli/commands/log.ts index 954a3c2..cef5567 100644 --- a/src/cli/commands/log.ts +++ b/src/cli/commands/log.ts @@ -274,7 +274,7 @@ export const log = Cli.create('log', { ), ) if (!logged.ok) return c.error({ code: logged.code, message: logged.message }) - const { id } = logged.value.entry + const { id, title: loggedTitle } = logged.value.entry const file = logged.value.location // Reached interactively, or on request. The editor is the long-form input path. @@ -296,7 +296,7 @@ export const log = Cli.create('log', { ...(store.name === 'file' ? { artifacts: Store.toArtifacts(id) } : {}), file, id, - title, + title: loggedTitle, }, { cta: { @@ -367,7 +367,7 @@ export const log = Cli.create('log', { artifacts: Store.toArtifacts(id), file, id, - title, + title: entry.value.title, ...(filed.ok && 'issue' in filed.value ? { issue: filed.value.issue } : {}), ...(unfiled ? { unfiled } : {}), }, diff --git a/src/cli/commands/resolve.test.ts b/src/cli/commands/resolve.test.ts index 8a87c13..ea09a06 100644 --- a/src/cli/commands/resolve.test.ts +++ b/src/cli/commands/resolve.test.ts @@ -31,3 +31,23 @@ test('security: rejects path traversal without removing parent directories', asy }) await expect(fs.readFile(`${cwd}/.agents/keep.txt`, 'utf8')).resolves.toBe('keep') }) + +test('behavior: passes opaque ids to configured stores', async () => { + const cwd = await helpers.repo() + const store = Store.from({ + name: 'custom', + read: async () => [], + get: async () => { + throw new Error('Unexpected get.') + }, + write: async () => { + throw new Error('Unexpected write.') + }, + remove: async (id) => id === 'ticket/123', + }) + + await expect(cli.data(['resolve', 'ticket/123', '--cwd', cwd], {}, { store })).resolves.toEqual({ + id: 'ticket/123', + removed: true, + }) +}) diff --git a/src/cli/commands/resolve.ts b/src/cli/commands/resolve.ts index 4e9443e..8345c9c 100644 --- a/src/cli/commands/resolve.ts +++ b/src/cli/commands/resolve.ts @@ -13,7 +13,7 @@ export const resolve = Cli.create('resolve', { async run(c) { const { root } = await context.resolve({ cwd: c.options.cwd }) const store = c.var.store ?? Store.file({ root }) - if (!Store.isId(c.args.id)) + if (c.var.store === undefined && !Store.isId(c.args.id)) return c.error({ code: 'INVALID_ENTRY_ID', message: 'Entry id must be one path-safe directory name.', From 8fca846b1855c306b4629fc8353ef3ccb17032ba Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Tue, 4 Aug 2026 13:47:07 +1000 Subject: [PATCH 14/26] feat: accept postgres connection strings --- .changeset/calm-frogs-store.md | 5 ++-- README.md | 6 ++-- package.json | 10 +------ pnpm-lock.yaml | 6 ++-- src/Store.postgres.test.ts | 45 ++++++++++++++++-------------- src/Store.test-d.ts | 16 ++++++++--- src/Store.test.ts | 1 + src/Store.ts | 39 ++++++++++++-------------- src/cli/Cli.test.ts | 51 ---------------------------------- src/cli/internal/store.ts | 26 ++++++----------- test/postgres.ts | 31 +++++++++++++++++---- 11 files changed, 95 insertions(+), 141 deletions(-) diff --git a/.changeset/calm-frogs-store.md b/.changeset/calm-frogs-store.md index cbf0723..a655bee 100644 --- a/.changeset/calm-frogs-store.md +++ b/.changeset/calm-frogs-store.md @@ -6,10 +6,8 @@ Added pluggable friction stores, a Postgres factory, and the `Frog.create` loggi ```ts import { Frog, Store } from 'frog' -import { Pool } from 'pg' -const client = new Pool({ connectionString: process.env.DATABASE_URL }) -const store = Store.postgres({ client, namespace: 'support-agent' }) +const store = Store.postgres({ connectionString: process.env.DATABASE_URL! }) await store.migrate() const frog = Frog.create({ store }) await frog.log({ @@ -18,4 +16,5 @@ await frog.log({ title: 'Tool required a workaround', }) const logs = await frog.logs() +await store.close() ``` diff --git a/README.md b/README.md index 2568e29..fac03bd 100644 --- a/README.md +++ b/README.md @@ -190,7 +190,6 @@ FROG_DATABASE_URL=postgres://... frog migrate FROG_DATABASE_URL=postgres://... frog list ``` -Install `pg` beside Frog when using Postgres (and `@types/pg` in TypeScript projects). `FROG_NAMESPACE` can isolate several consumers in one database (it defaults to `default`), and `FROG_SCHEMA` can place the table in a specific schema. An unrelated application `DATABASE_URL` does not change Frog's default store. @@ -199,10 +198,8 @@ Applications use the same store through the programmatic API: ```ts import { Frog, Store } from 'frog' -import { Pool } from 'pg' -const client = new Pool({ connectionString: process.env.DATABASE_URL }) -const store = Store.postgres({ client, namespace: 'support-agent' }) +const store = Store.postgres({ connectionString: process.env.DATABASE_URL! }) await store.migrate() const frog = Frog.create({ store }) @@ -214,6 +211,7 @@ const result = await frog.log({ }) const unresolved = await frog.logs() // canonical entries with deduplicated occurrence counts +await store.close() ``` Every store implements `Store.Store` and preserves the same `Entry` fields. Use `Store.from` to adapt a diff --git a/package.json b/package.json index 78345d8..c5ca04c 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,6 @@ "@types/node": "catalog:", "@types/pg": "catalog:", "@vitest/coverage-v8": "catalog:", - "pg": "catalog:", "tsx": "catalog:", "typescript": "catalog:", "vite-plus": "catalog:", @@ -65,16 +64,9 @@ "@clack/prompts": "catalog:", "@octokit/rest": "catalog:", "incur": "catalog:", + "pg": "catalog:", "yaml": "catalog:" }, - "peerDependencies": { - "pg": ">=8.0.0" - }, - "peerDependenciesMeta": { - "pg": { - "optional": true - } - }, "engines": { "node": ">=22" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5c9ebc4..d2b7fa6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,6 +71,9 @@ importers: incur: specifier: 'catalog:' version: 0.4.25 + pg: + specifier: 'catalog:' + version: 8.22.0 yaml: specifier: 'catalog:' version: 2.9.0 @@ -90,9 +93,6 @@ importers: '@vitest/coverage-v8': specifier: 'catalog:' version: 4.1.10(@vitest/browser@4.1.10)(vitest@4.1.10) - pg: - specifier: 'catalog:' - version: 8.22.0 tsx: specifier: 'catalog:' version: 4.23.1 diff --git a/src/Store.postgres.test.ts b/src/Store.postgres.test.ts index 0649518..a6a7593 100644 --- a/src/Store.postgres.test.ts +++ b/src/Store.postgres.test.ts @@ -16,7 +16,7 @@ const postgres = Postgres.get() describe('postgres', () => { test('behavior: migration creates the configured schema and is idempotent', async () => { const client = postgres.client() - const store = Store.postgres({ client, namespace: 'consumer-a', schema: 'frog' }) + const store = postgres.create({ namespace: 'consumer-a', schema: 'frog' }) const table = async () => client.query<{ table_name: string }>( `SELECT table_name @@ -33,7 +33,7 @@ describe('postgres', () => { test('behavior: an omitted schema follows the client search path', async () => { const client = postgres.client() - const store = Store.postgres({ client, namespace: 'search-path' }) + const store = postgres.create({ namespace: 'search-path' }) const frog = Frog.create({ store }) await store.migrate() @@ -81,9 +81,8 @@ describe('postgres', () => { }) test('behavior: namespaces isolate consumers and force preserves intentional duplicates', async () => { - const client = postgres.client() - const first = Frog.create({ store: Store.postgres({ client, namespace: 'one' }) }) - const second = Frog.create({ store: Store.postgres({ client, namespace: 'two' }) }) + const first = Frog.create({ store: postgres.create({ namespace: 'one' }) }) + const second = Frog.create({ store: postgres.create({ namespace: 'two' }) }) await first.store.migrate() await first.log(friction) @@ -93,29 +92,33 @@ describe('postgres', () => { expect(await second.logs()).toHaveLength(1) }) - test('behavior: removal uses returned rows when the client omits rowCount', async () => { - const client: Store.postgres.Client = { - async query = Record>( - text: string, - values?: unknown[], - ): Promise<{ rows: T[] }> { - const result = await postgres.client().query(text, values) - return { rows: result.rows } - }, - } - const store = Store.postgres({ client, namespace: 'remove-without-row-count' }) + test('behavior: namespace defaults to default', async () => { + const store = Store.postgres({ connectionString: postgres.connectionString() }) + onTestFinished(() => store.close()) + await store.migrate() + const written = await Frog.create({ store }).log(friction) + + expect(written.location).toMatch(/^postgres:default\//) + }) + + test('behavior: closing the owned pool is idempotent', async () => { + const store = postgres.create() await store.migrate() - const written = await store.write(friction) - await expect(store.remove(written.id)).resolves.toBe(true) - await expect(store.remove(written.id)).resolves.toBe(false) + await expect(store.close()).resolves.toBeUndefined() + await expect(store.close()).resolves.toBeUndefined() + }) + + test('error: rejects an empty connection string', () => { + expect(() => Store.postgres({ connectionString: ' ' })).toThrow( + 'Postgres connectionString is required.', + ) }) test('error: rejects unsafe schema names before issuing SQL', () => { expect(() => Store.postgres({ - client: postgres.client(), - namespace: 'one', + connectionString: postgres.connectionString(), schema: 'public; DROP TABLE users', }), ).toThrow('Postgres schema must be a SQL identifier.') diff --git a/src/Store.test-d.ts b/src/Store.test-d.ts index 7e5515b..def5b7f 100644 --- a/src/Store.test-d.ts +++ b/src/Store.test-d.ts @@ -1,10 +1,18 @@ import * as Store from './Store.js' -declare const client: Store.postgres.Client declare const value: Store.from.Value expectTypeOf(Store.from(value)).toEqualTypeOf() expectTypeOf(Store.file({ root: '/repo' })).toEqualTypeOf() -expectTypeOf(Store.postgres({ client, namespace: 'agent' })).toEqualTypeOf() -// @ts-expect-error Postgres configuration is supplied through one options object. -Store.postgres(client, { namespace: 'agent' }) +expectTypeOf( + Store.postgres({ connectionString: 'postgres://localhost/frog' }), +).toEqualTypeOf() +expectTypeOf( + Store.postgres({ + connectionString: 'postgres://localhost/frog', + namespace: 'agent', + schema: 'frog', + }), +).toEqualTypeOf() +// @ts-expect-error Postgres requires a connection string. +Store.postgres({ namespace: 'agent' }) diff --git a/src/Store.test.ts b/src/Store.test.ts index 85c1529..688beef 100644 --- a/src/Store.test.ts +++ b/src/Store.test.ts @@ -32,6 +32,7 @@ test('behavior: from derives optional operations for a custom store', async () = expect(store.tracksOccurrences).toBe(false) expect(store.location('memory-id')).toBe('memory-id') await expect(store.migrate()).resolves.toBeUndefined() + await expect(store.close()).resolves.toBeUndefined() await expect(store.write(value)).resolves.toEqual({ id: 'memory-id', location: 'memory:memory-id', diff --git a/src/Store.ts b/src/Store.ts index e2edb1a..c9bc278 100644 --- a/src/Store.ts +++ b/src/Store.ts @@ -1,6 +1,7 @@ import { randomUUID } from 'node:crypto' import fs from 'node:fs/promises' import path from 'node:path' +import { Pool } from 'pg' import * as Entry from './Entry.js' /** Directory holding entries, relative to the repository root. */ @@ -56,6 +57,8 @@ export type Store = { readonly tracksOccurrences: boolean /** Prepares store-owned storage. Safe to call repeatedly. */ readonly migrate: () => Promise + /** Releases store-owned resources. Safe to call repeatedly. */ + readonly close: () => Promise /** Lists every entry in stable id order. */ readonly read: () => Promise /** Lists entries with recurrence metadata. */ @@ -90,6 +93,7 @@ export function from(value: from.Value): Store { name: value.name, tracksOccurrences: value.tracksOccurrences ?? value.records !== undefined, migrate: value.migrate ?? (async () => {}), + close: value.close ?? (async () => {}), read: value.read, records: value.records ?? @@ -121,6 +125,8 @@ export declare namespace from { readonly remove: (id: string) => Promise /** Prepares store-owned storage. Safe to call repeatedly. */ readonly migrate?: (() => Promise) | undefined + /** Releases store-owned resources. Safe to call repeatedly. */ + readonly close?: (() => Promise) | undefined /** Lists entries with recurrence metadata. Derived from `read` by default. */ readonly records?: (() => Promise) | undefined /** Lists entry ids in stable order. Derived from `read` by default. */ @@ -384,11 +390,13 @@ type PostgresRow = { occurrence_count: number | string } -/** Creates a Postgres-backed store from a `pg`-compatible client and namespace. */ +/** Creates a Postgres-backed store from a connection string. */ export function postgres(options: postgres.Options): Store { - const { client } = options - const namespace = required(options.namespace, 'namespace') + const connectionString = required(options.connectionString, 'connectionString') + const namespace = required(options.namespace ?? 'default', 'namespace') const table = tableName(options.schema) + const client = new Pool({ allowExitOnIdle: true, connectionString }) + let closing: Promise | undefined const get = async (id: string): Promise => { const result = await client.query( @@ -404,6 +412,7 @@ export function postgres(options: postgres.Options): Store { name: 'postgres', tracksOccurrences: true, location: (id) => postgresLocation(namespace, id), + close: () => (closing ??= client.end()), async migrate() { if (options.schema !== undefined) await client.query(`CREATE SCHEMA IF NOT EXISTS "${schemaName(options.schema)}"`) @@ -498,27 +507,13 @@ export function postgres(options: postgres.Options): Store { } export declare namespace postgres { - /** Minimal structural client implemented by `pg` pools and transaction clients. */ - type Client = { - /** Executes SQL with optional parameter values. */ - query = Record>( - text: string, - values?: unknown[], - ): Promise<{ - /** Number of affected rows when the driver supplies it. */ - rowCount?: number | null | undefined - /** Query result rows. */ - rows: T[] - }> - } - /** Postgres store configuration. */ type Options = { - /** `pg`-compatible pool or transaction client. */ - client: Client - /** Isolates independent consumers sharing one table. */ - namespace: string - /** Optional PostgreSQL schema. Omit it to use the client's current search path. */ + /** PostgreSQL connection URL. */ + connectionString: string + /** Isolates independent consumers sharing one table. Defaults to `default`. */ + namespace?: string | undefined + /** Optional PostgreSQL schema. Omit it to use the default search path. */ schema?: string | undefined } } diff --git a/src/cli/Cli.test.ts b/src/cli/Cli.test.ts index 98b1e55..e3ba0bc 100644 --- a/src/cli/Cli.test.ts +++ b/src/cli/Cli.test.ts @@ -1,7 +1,3 @@ -import * as childProcess from 'node:child_process' -import * as fs from 'node:fs/promises' -import * as path from 'node:path' -import * as helpers from '../../test/helpers.js' import { serve } from './Cli.js' test.each([['--version'], ['list', '--help'], ['--schema']])( @@ -20,50 +16,3 @@ test.each([['--version'], ['list', '--help'], ['--schema']])( expect(output).toBeTruthy() }, ) - -test('error: store setup failures use the requested output format', async () => { - const root = await helpers.tmpdir() - const packageRoot = path.join(root, 'frog') - await fs.cp(path.resolve(import.meta.dirname, '..'), path.join(packageRoot, 'src'), { - recursive: true, - }) - await fs.writeFile(path.join(packageRoot, 'package.json'), '{"type":"module"}\n') - for (const dependency of ['@clack/prompts', '@octokit/rest', 'incur', 'yaml']) { - const link = path.join(packageRoot, 'node_modules', dependency) - await fs.mkdir(path.dirname(link), { recursive: true }) - await fs.symlink(path.resolve(import.meta.dirname, '../../node_modules', dependency), link) - } - - const result = await new Promise<{ code: number | null; stderr: string; stdout: string }>( - (resolve, reject) => { - const child = childProcess.spawn( - process.execPath, - [ - '--import', - import.meta.resolve('tsx'), - path.join(packageRoot, 'src/bin.ts'), - 'list', - '--format', - 'json', - ], - { - cwd: packageRoot, - env: { ...process.env, FROG_DATABASE_URL: 'postgres://driver-must-not-load' }, - }, - ) - let stderr = '' - let stdout = '' - child.stderr.on('data', (chunk) => (stderr += chunk)) - child.stdout.on('data', (chunk) => (stdout += chunk)) - child.on('error', reject) - child.on('close', (code) => resolve({ code, stderr, stdout })) - }, - ) - - expect(result.code).toBe(1) - expect(result.stderr).toBe('') - expect(JSON.parse(result.stdout)).toEqual({ - code: 'UNKNOWN', - message: 'The Postgres CLI store requires the optional `pg` package.', - }) -}) diff --git a/src/cli/internal/store.ts b/src/cli/internal/store.ts index 4014272..1748e82 100644 --- a/src/cli/internal/store.ts +++ b/src/cli/internal/store.ts @@ -1,4 +1,3 @@ -import { createRequire } from 'node:module' import { z } from 'incur' import * as Store from '../../Store.js' @@ -35,27 +34,18 @@ export function configuration(env: Environment): Configuration { } } -/** Resolves the optional CLI store without making a database driver a hard Frog dependency. */ +/** Resolves the optional CLI store. */ export async function resolve(env: Environment): Promise { const selected = configuration(env) if (selected.kind === 'file') return undefined - const require = createRequire(import.meta.url) - let Pool: new (options: { connectionString: string }) => Store.postgres.Client & { - end(): Promise - } - try { - ;({ Pool } = require('pg') as { Pool: typeof Pool }) - } catch (error) { - throw new Error('The Postgres CLI store requires the optional `pg` package.', { cause: error }) - } - const client = new Pool({ connectionString: selected.connectionString }) + const store = Store.postgres({ + connectionString: selected.connectionString, + namespace: selected.namespace, + ...(selected.schema ? { schema: selected.schema } : {}), + }) return { - store: Store.postgres({ - client, - namespace: selected.namespace, - ...(selected.schema ? { schema: selected.schema } : {}), - }), - close: () => client.end(), + store, + close: store.close, } } diff --git a/test/postgres.ts b/test/postgres.ts index 64b7d46..1704815 100644 --- a/test/postgres.ts +++ b/test/postgres.ts @@ -9,10 +9,12 @@ const image = 'postgres:18-alpine' export function get(): get.ReturnType { let container: StartedPostgreSqlContainer | undefined let client: Pool | undefined + let connectionString: string | undefined beforeAll(async () => { container = await new PostgreSqlContainer(image).start() - client = new Pool({ connectionString: container.getConnectionUri() }) + connectionString = container.getConnectionUri() + client = new Pool({ connectionString }) }, 120_000) afterAll(async () => { @@ -28,14 +30,27 @@ export function get(): get.ReturnType { return client } + const getConnectionString = () => { + if (!connectionString) throw new Error('Postgres test container has not started.') + return connectionString + } + + const create = (options: get.Options = {}) => { + const store = Store.postgres({ + connectionString: getConnectionString(), + namespace: options.namespace ?? randomUUID(), + ...(options.schema ? { schema: options.schema } : {}), + }) + onTestFinished(() => store.close()) + return store + } + return { client: getClient, + connectionString: getConnectionString, + create, async store(options = {}) { - const store = Store.postgres({ - client: getClient(), - namespace: options.namespace ?? randomUUID(), - ...(options.schema ? { schema: options.schema } : {}), - }) + const store = create(options) await store.migrate() return store }, @@ -55,6 +70,10 @@ export declare namespace get { type ReturnType = { /** Returns the connected pool after the test hook starts the container. */ readonly client: () => Pool + /** Returns the container connection string after the test hook starts the container. */ + readonly connectionString: () => string + /** Creates an unmigrated store with an isolated namespace. */ + readonly create: (options?: Options) => Store.Store /** Creates and migrates a store with an isolated namespace. */ readonly store: (options?: Options) => Promise } From f906b82726ec24c72f4e180c8b57af2462df0ca1 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Tue, 4 Aug 2026 13:56:33 +1000 Subject: [PATCH 15/26] feat: use postgres.js for stores --- .changeset/calm-frogs-store.md | 2 +- README.md | 3 +- package.json | 3 +- pnpm-lock.yaml | 134 ++------------------------- pnpm-workspace.yaml | 3 +- src/Store.postgres.test.ts | 26 +++--- src/Store.test.ts | 9 ++ src/Store.ts | 159 +++++++++++++++++---------------- src/cli/commands/list.test.ts | 28 ++++++ src/cli/commands/list.ts | 5 +- src/cli/commands/log.test.ts | 12 +++ src/cli/commands/log.ts | 5 ++ test/postgres.ts | 10 +-- 13 files changed, 172 insertions(+), 227 deletions(-) diff --git a/.changeset/calm-frogs-store.md b/.changeset/calm-frogs-store.md index a655bee..ab4ee9f 100644 --- a/.changeset/calm-frogs-store.md +++ b/.changeset/calm-frogs-store.md @@ -2,7 +2,7 @@ 'frog': minor --- -Added pluggable friction stores, a Postgres factory, and the `Frog.create` logging API. +Added pluggable friction stores, a Postgres.js-backed `Store.postgres({ connectionString })` factory, and the `Frog.create` logging API. ```ts import { Frog, Store } from 'frog' diff --git a/README.md b/README.md index fac03bd..d839f9e 100644 --- a/README.md +++ b/README.md @@ -190,7 +190,8 @@ FROG_DATABASE_URL=postgres://... frog migrate FROG_DATABASE_URL=postgres://... frog list ``` -`FROG_NAMESPACE` can isolate several consumers in one database (it defaults to `default`), and +Frog includes Postgres.js and owns the store's connection pool. `FROG_NAMESPACE` can isolate several +consumers in one database (it defaults to `default`), and `FROG_SCHEMA` can place the table in a specific schema. An unrelated application `DATABASE_URL` does not change Frog's default store. diff --git a/package.json b/package.json index c5ca04c..657fead 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,6 @@ "@changesets/cli": "catalog:", "@testcontainers/postgresql": "catalog:", "@types/node": "catalog:", - "@types/pg": "catalog:", "@vitest/coverage-v8": "catalog:", "tsx": "catalog:", "typescript": "catalog:", @@ -64,7 +63,7 @@ "@clack/prompts": "catalog:", "@octokit/rest": "catalog:", "incur": "catalog:", - "pg": "catalog:", + "postgres": "catalog:", "yaml": "catalog:" }, "engines": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d2b7fa6..897785c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,9 +24,6 @@ catalogs: '@types/node': specifier: latest version: 26.1.1 - '@types/pg': - specifier: ^8.20.3 - version: 8.20.3 '@vitest/coverage-v8': specifier: latest version: 4.1.10 @@ -36,9 +33,9 @@ catalogs: octokit: specifier: ^5.0.5 version: 5.0.5 - pg: - specifier: ^8.22.0 - version: 8.22.0 + postgres: + specifier: ^3.4.9 + version: 3.4.9 tsx: specifier: ^4.23.1 version: 4.23.1 @@ -71,9 +68,9 @@ importers: incur: specifier: 'catalog:' version: 0.4.25 - pg: + postgres: specifier: 'catalog:' - version: 8.22.0 + version: 3.4.9 yaml: specifier: 'catalog:' version: 2.9.0 @@ -87,9 +84,6 @@ importers: '@types/node': specifier: 'catalog:' version: 26.1.1 - '@types/pg': - specifier: 'catalog:' - version: 8.20.3 '@vitest/coverage-v8': specifier: 'catalog:' version: 4.1.10(@vitest/browser@4.1.10)(vitest@4.1.10) @@ -1416,9 +1410,6 @@ packages: '@types/node@26.1.1': resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==} - '@types/pg@8.20.3': - resolution: {integrity: sha512-4Tvg+HO6+oQaAkpT8GTYoSExzpGGZz532GXgbbCElWJQeQdMozBWxEKNBhJJpHFjWXsMxqPbyypvj/89FWNoSQ==} - '@types/ssh2-streams@0.1.13': resolution: {integrity: sha512-faHyY3brO9oLEA0QlcO8N2wT7R0+1sHWZvQ+y3rMLwdY1ZyS1z0W3t65j9PqT4HmQ6ALzNe7RZlNuCNE0wBSWA==} @@ -2599,40 +2590,6 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - pg-cloudflare@1.4.0: - resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} - - pg-connection-string@2.14.0: - resolution: {integrity: sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==} - - pg-int8@1.0.1: - resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} - engines: {node: '>=4.0.0'} - - pg-pool@3.14.0: - resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==} - peerDependencies: - pg: '>=8.0' - - pg-protocol@1.15.0: - resolution: {integrity: sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==} - - pg-types@2.2.0: - resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} - engines: {node: '>=4'} - - pg@8.22.0: - resolution: {integrity: sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==} - engines: {node: '>= 16.0.0'} - peerDependencies: - pg-native: '>=3.0.1' - peerDependenciesMeta: - pg-native: - optional: true - - pgpass@1.0.5: - resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} - picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2656,21 +2613,9 @@ packages: resolution: {integrity: sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==} engines: {node: ^10 || ^12 || >=14} - postgres-array@2.0.0: - resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} - engines: {node: '>=4'} - - postgres-bytea@1.0.1: - resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} - engines: {node: '>=0.10.0'} - - postgres-date@1.0.7: - resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} - engines: {node: '>=0.10.0'} - - postgres-interval@1.2.0: - resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} - engines: {node: '>=0.10.0'} + postgres@3.4.9: + resolution: {integrity: sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==} + engines: {node: '>=12'} prettier@2.8.8: resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==} @@ -2810,10 +2755,6 @@ packages: split-ca@1.0.1: resolution: {integrity: sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==} - split2@4.2.0: - resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} - engines: {node: '>= 10.x'} - sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} @@ -3145,10 +3086,6 @@ packages: utf-8-validate: optional: true - xtend@4.0.2: - resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} - engines: {node: '>=0.4'} - y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -4225,12 +4162,6 @@ snapshots: dependencies: undici-types: 8.3.0 - '@types/pg@8.20.3': - dependencies: - '@types/node': 26.1.1 - pg-protocol: 1.15.0 - pg-types: 2.2.0 - '@types/ssh2-streams@0.1.13': dependencies: '@types/node': 26.1.1 @@ -5260,41 +5191,6 @@ snapshots: pathe@2.0.3: {} - pg-cloudflare@1.4.0: - optional: true - - pg-connection-string@2.14.0: {} - - pg-int8@1.0.1: {} - - pg-pool@3.14.0(pg@8.22.0): - dependencies: - pg: 8.22.0 - - pg-protocol@1.15.0: {} - - pg-types@2.2.0: - dependencies: - pg-int8: 1.0.1 - postgres-array: 2.0.0 - postgres-bytea: 1.0.1 - postgres-date: 1.0.7 - postgres-interval: 1.2.0 - - pg@8.22.0: - dependencies: - pg-connection-string: 2.14.0 - pg-pool: 3.14.0(pg@8.22.0) - pg-protocol: 1.15.0 - pg-types: 2.2.0 - pgpass: 1.0.5 - optionalDependencies: - pg-cloudflare: 1.4.0 - - pgpass@1.0.5: - dependencies: - split2: 4.2.0 - picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -5311,15 +5207,7 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - postgres-array@2.0.0: {} - - postgres-bytea@1.0.1: {} - - postgres-date@1.0.7: {} - - postgres-interval@1.2.0: - dependencies: - xtend: 4.0.2 + postgres@3.4.9: {} prettier@2.8.8: {} @@ -5510,8 +5398,6 @@ snapshots: split-ca@1.0.1: {} - split2@4.2.0: {} - sprintf-js@1.0.3: {} ssh-remote-port-forward@1.0.4: @@ -5888,8 +5774,6 @@ snapshots: ws@8.21.1: {} - xtend@4.0.2: {} - y18n@5.0.8: {} yaml@2.9.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f18acd6..0781004 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -9,11 +9,10 @@ catalog: '@octokit/rest': ^22.0.1 '@testcontainers/postgresql': ^12.0.4 '@types/node': latest - '@types/pg': ^8.20.3 '@vitest/coverage-v8': latest incur: 0.4.25 octokit: ^5.0.5 - pg: ^8.22.0 + postgres: ^3.4.9 tsx: ^4.23.1 typescript: latest vite-plus: latest diff --git a/src/Store.postgres.test.ts b/src/Store.postgres.test.ts index a6a7593..056ba9c 100644 --- a/src/Store.postgres.test.ts +++ b/src/Store.postgres.test.ts @@ -18,17 +18,17 @@ describe('postgres', () => { const client = postgres.client() const store = postgres.create({ namespace: 'consumer-a', schema: 'frog' }) const table = async () => - client.query<{ table_name: string }>( - `SELECT table_name - FROM information_schema.tables - WHERE table_schema = 'frog' AND table_name = 'frog_entries'`, - ) + client<{ table_name: string }[]>` + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'frog' AND table_name = 'frog_entries' + ` - await expect(table()).resolves.toMatchObject({ rows: [] }) + await expect(table()).resolves.toEqual([]) await store.migrate() await store.migrate() - await expect(table()).resolves.toMatchObject({ rows: [{ table_name: 'frog_entries' }] }) + await expect(table()).resolves.toEqual([{ table_name: 'frog_entries' }]) }) test('behavior: an omitted schema follows the client search path', async () => { @@ -39,12 +39,12 @@ describe('postgres', () => { await store.migrate() await frog.log(friction) - const result = await client.query<{ table_schema: string }>( - `SELECT table_schema - FROM information_schema.tables - WHERE table_schema = current_schema() AND table_name = 'frog_entries'`, - ) - expect(result.rows).toEqual([{ table_schema: 'public' }]) + const result = await client<{ table_schema: string }[]>` + SELECT table_schema + FROM information_schema.tables + WHERE table_schema = current_schema() AND table_name = 'frog_entries' + ` + expect(result).toEqual([{ table_schema: 'public' }]) }) test('behavior: logs, deduplicates, updates, lists, and removes', async () => { diff --git a/src/Store.test.ts b/src/Store.test.ts index 688beef..bd80bdf 100644 --- a/src/Store.test.ts +++ b/src/Store.test.ts @@ -150,6 +150,15 @@ describe('list', () => { expect(await Store.list({ root })).toEqual(['real']) }) + test('behavior: unsafe directory names are ignored', async () => { + const root = await tmpdir() + await write('real', root) + for (const id of ['trailing.', 'trailing ', 'nested\\alias']) + await writeFile(`${Store.dir}/${id}/${Store.filename}`, entry, root) + + expect(await Store.list({ root })).toEqual(['real']) + }) + test('behavior: a missing directory is not an error', async () => { expect(await Store.list({ root: await tmpdir() })).toEqual([]) }) diff --git a/src/Store.ts b/src/Store.ts index c9bc278..ca73730 100644 --- a/src/Store.ts +++ b/src/Store.ts @@ -1,7 +1,7 @@ import { randomUUID } from 'node:crypto' import fs from 'node:fs/promises' import path from 'node:path' -import { Pool } from 'pg' +import postgresjs from 'postgres' import * as Entry from './Entry.js' /** Directory holding entries, relative to the repository root. */ @@ -53,6 +53,8 @@ export type LogResult = StoredEntry & { export type Store = { /** Stable store name for diagnostics and capability checks. */ readonly name: string + /** Repository root when the store is bound to local files. */ + readonly root?: string | undefined /** Whether the store preserves occurrence counts beyond the canonical entry. */ readonly tracksOccurrences: boolean /** Prepares store-owned storage. Safe to call repeatedly. */ @@ -91,6 +93,7 @@ export type LogOptions = { export function from(value: from.Value): Store { return { name: value.name, + ...(value.root !== undefined ? { root: value.root } : {}), tracksOccurrences: value.tracksOccurrences ?? value.records !== undefined, migrate: value.migrate ?? (async () => {}), close: value.close ?? (async () => {}), @@ -113,6 +116,8 @@ export declare namespace from { type Value = { /** Stable store name for diagnostics and capability checks. */ readonly name: string + /** Repository root when the store is bound to local files. */ + readonly root?: string | undefined /** Whether the store preserves occurrence counts beyond the canonical entry. */ readonly tracksOccurrences?: boolean | undefined /** Lists every entry in stable id order. */ @@ -144,17 +149,19 @@ export declare namespace from { /** Binds the repository-file store to one root. */ export function file(options: file.Options): Store { + const root = path.resolve(options.root) return from({ name: 'file', - read: () => read(options), - list: () => list(options), - get: (id) => get(id, options), + root, + read: () => read({ root }), + list: () => list({ root }), + get: (id) => get(id, { root }), write: async (entry, writeOptions = {}) => { - const written = await write(entry, { ...writeOptions, root: options.root }) + const written = await write(entry, { ...writeOptions, root }) return { id: written.id, location: written.file } }, - remove: (id) => remove(id, options), - files: (id) => files(id, options), + remove: (id) => remove(id, { root }), + files: (id) => files(id, { root }), location: toPath, }) } @@ -259,7 +266,7 @@ export async function list(options: Options): Promise { }) const ids = found - .filter((entry) => entry.isDirectory() && !entry.name.startsWith('.')) + .filter((entry) => entry.isDirectory() && isId(entry.name)) .map((entry) => entry.name) .sort() @@ -394,16 +401,19 @@ type PostgresRow = { export function postgres(options: postgres.Options): Store { const connectionString = required(options.connectionString, 'connectionString') const namespace = required(options.namespace ?? 'default', 'namespace') - const table = tableName(options.schema) - const client = new Pool({ allowExitOnIdle: true, connectionString }) + const sql = postgresjs(connectionString) + const schema = options.schema === undefined ? undefined : schemaName(options.schema) + const table = + schema === undefined ? sql('frog_entries') : sql`${sql(schema)}.${sql('frog_entries')}` let closing: Promise | undefined const get = async (id: string): Promise => { - const result = await client.query( - `SELECT id, contents, occurrence_count FROM ${table} WHERE namespace = $1 AND id = $2`, - [namespace, id], - ) - const row = result.rows[0] + const result = await sql` + SELECT id, contents, occurrence_count + FROM ${table} + WHERE namespace = ${namespace} AND id = ${id} + ` + const row = result[0] if (!row) throw notFoundError(id) return Entry.parse(row.contents, { id: row.id }) } @@ -412,89 +422,88 @@ export function postgres(options: postgres.Options): Store { name: 'postgres', tracksOccurrences: true, location: (id) => postgresLocation(namespace, id), - close: () => (closing ??= client.end()), + close: () => (closing ??= sql.end()), async migrate() { - if (options.schema !== undefined) - await client.query(`CREATE SCHEMA IF NOT EXISTS "${schemaName(options.schema)}"`) - await client.query( - `CREATE TABLE IF NOT EXISTS ${table} ( - namespace text NOT NULL, - id text NOT NULL, - dedupe_key text NOT NULL, - contents text NOT NULL, - occurrence_count integer NOT NULL DEFAULT 1 CHECK (occurrence_count > 0), - created_at timestamptz NOT NULL DEFAULT now(), - updated_at timestamptz NOT NULL DEFAULT now(), - PRIMARY KEY (namespace, id), - UNIQUE (namespace, dedupe_key) - )`, - ) + if (schema !== undefined) await sql`CREATE SCHEMA IF NOT EXISTS ${sql(schema)}` + await sql` + CREATE TABLE IF NOT EXISTS ${table} ( + namespace text NOT NULL, + id text NOT NULL, + dedupe_key text NOT NULL, + contents text NOT NULL, + occurrence_count integer NOT NULL DEFAULT 1 CHECK (occurrence_count > 0), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (namespace, id), + UNIQUE (namespace, dedupe_key) + ) + ` }, async read() { - const result = await client.query( - `SELECT id, contents, occurrence_count FROM ${table} WHERE namespace = $1 ORDER BY id`, - [namespace], - ) - return result.rows.map((row) => Entry.parse(row.contents, { id: row.id })) + const result = await sql` + SELECT id, contents, occurrence_count + FROM ${table} + WHERE namespace = ${namespace} + ORDER BY id + ` + return result.map((row) => Entry.parse(row.contents, { id: row.id })) }, async records() { - const result = await client.query( - `SELECT id, contents, occurrence_count FROM ${table} WHERE namespace = $1 ORDER BY id`, - [namespace], - ) - return result.rows.map((row) => ({ + const result = await sql` + SELECT id, contents, occurrence_count + FROM ${table} + WHERE namespace = ${namespace} + ORDER BY id + ` + return result.map((row) => ({ entry: Entry.parse(row.contents, { id: row.id }), occurrences: Number(row.occurrence_count), })) }, async list() { - const result = await client.query<{ id: string }>( - `SELECT id FROM ${table} WHERE namespace = $1 ORDER BY id`, - [namespace], - ) - return result.rows.map((row) => row.id) + const result = await sql<{ id: string }[]>` + SELECT id FROM ${table} WHERE namespace = ${namespace} ORDER BY id + ` + return result.map((row) => row.id) }, get, async write(entry, writeOptions = {}) { const id = writeOptions.id ?? newPostgresId(entry.title) const dedupeKey = `entry:${id}` const titleKey = `title:${Entry.normalizeTitle(entry.title)}` - await client.query( - `INSERT INTO ${table}(namespace, id, dedupe_key, contents) - VALUES ($1, $2, $3, $4) - ON CONFLICT(namespace, id) DO UPDATE SET - dedupe_key = CASE - WHEN ${table}.dedupe_key LIKE 'title:%' THEN $5 - ELSE ${table}.dedupe_key - END, - contents = EXCLUDED.contents, - updated_at = now()`, - [namespace, id, dedupeKey, Entry.serialize(entry), titleKey], - ) + await sql` + INSERT INTO ${table}(namespace, id, dedupe_key, contents) + VALUES (${namespace}, ${id}, ${dedupeKey}, ${Entry.serialize(entry)}) + ON CONFLICT(namespace, id) DO UPDATE SET + dedupe_key = CASE + WHEN ${table}.dedupe_key LIKE 'title:%' THEN ${titleKey} + ELSE ${table}.dedupe_key + END, + contents = EXCLUDED.contents, + updated_at = now() + ` return { id, location: postgresLocation(namespace, id) } }, async remove(id) { - const result = await client.query<{ id: string }>( - `DELETE FROM ${table} WHERE namespace = $1 AND id = $2 RETURNING id`, - [namespace, id], - ) - return result.rows.length > 0 + const result = await sql<{ id: string }[]>` + DELETE FROM ${table} WHERE namespace = ${namespace} AND id = ${id} RETURNING id + ` + return result.length > 0 }, async log(entry, logOptions = {}) { const id = newPostgresId(entry.title) const dedupeKey = logOptions.force ? `forced:${id}` : `title:${Entry.normalizeTitle(entry.title)}` - const result = await client.query( - `INSERT INTO ${table}(namespace, id, dedupe_key, contents) - VALUES ($1, $2, $3, $4) - ON CONFLICT(namespace, dedupe_key) DO UPDATE SET - occurrence_count = ${table}.occurrence_count + 1, - updated_at = now() - RETURNING id, contents, occurrence_count, (occurrence_count = 1) AS created`, - [namespace, id, dedupeKey, Entry.serialize(entry)], - ) - const row = result.rows[0] + const result = await sql` + INSERT INTO ${table}(namespace, id, dedupe_key, contents) + VALUES (${namespace}, ${id}, ${dedupeKey}, ${Entry.serialize(entry)}) + ON CONFLICT(namespace, dedupe_key) DO UPDATE SET + occurrence_count = ${table}.occurrence_count + 1, + updated_at = now() + RETURNING id, contents, occurrence_count, (occurrence_count = 1) AS created + ` + const row = result[0] if (!row) throw new Error('Postgres did not return the logged friction entry.') return { created: row.created === true, @@ -528,10 +537,6 @@ function schemaName(schema: string): string { return schema } -function tableName(schema?: string): string { - return schema === undefined ? '"frog_entries"' : `"${schemaName(schema)}"."frog_entries"` -} - function required(value: string, name: string): string { const normalized = value.trim() if (!normalized) throw new Error(`Postgres ${name} is required.`) diff --git a/src/cli/commands/list.test.ts b/src/cli/commands/list.test.ts index 187a6ab..ff5d88b 100644 --- a/src/cli/commands/list.test.ts +++ b/src/cli/commands/list.test.ts @@ -111,6 +111,34 @@ test('behavior: a custom store without recurrence metadata omits occurrence coun expect(result.entries[0]).not.toHaveProperty('occurrences') }) +test('behavior: custom stores expose opaque artifact locations', async () => { + const entry = { body, id: 'ticket/123', severity: 'minor', title: 'Opaque id' } as const + const store = Store.from({ + name: 'remote', + read: async () => [entry], + get: async () => entry, + write: async () => ({ id: entry.id, location: entry.id }), + remove: async () => false, + files: async () => ['remote://ticket/123/repro.ts'], + }) + + const result = await cli.data<{ entries: { artifacts?: string[]; id: string }[] }>( + ['list', '--cwd', await helpers.repo()], + {}, + { store }, + ) + + expect(result.entries).toEqual([ + { + artifacts: ['remote://ticket/123/repro.ts'], + id: 'ticket/123', + severity: 'minor', + state: 'pending', + title: 'Opaque id', + }, + ]) +}) + test('behavior: filters by state', async () => { const cwd = await helpers.repo() await seed(cwd) diff --git a/src/cli/commands/list.ts b/src/cli/commands/list.ts index 7701056..b3cd2ec 100644 --- a/src/cli/commands/list.ts +++ b/src/cli/commands/list.ts @@ -94,7 +94,10 @@ export const list = Cli.create('list', { const listed = await Promise.all( selected.map(async (entry) => { const files = await store.files(entry.id) - const artifacts = files.filter((file) => file.startsWith(`${Store.toArtifacts(entry.id)}/`)) + const artifacts = + store.name === 'file' + ? files.filter((file) => file.startsWith(`${Store.toArtifacts(entry.id)}/`)) + : [...files] return { id: entry.id, ...(store.tracksOccurrences ? { occurrences: occurrences.get(entry.id) ?? 1 } : {}), diff --git a/src/cli/commands/log.test.ts b/src/cli/commands/log.test.ts index e586852..9365fce 100644 --- a/src/cli/commands/log.test.ts +++ b/src/cli/commands/log.test.ts @@ -50,6 +50,18 @@ test('behavior: durable-store logging atomically records repeated titles', async expect(repeated.title).toBe(title) expect(await store.records()).toMatchObject([{ occurrences: 2 }]) }) + +test('error: an injected file store must match the command root', async () => { + const cwd = await helpers.repo() + const storageRoot = await helpers.repo() + const store = Store.file({ root: storageRoot }) + + expect( + await cli.error(['log', title, '--body', body, '--cwd', cwd], {}, { store }), + ).toMatchObject({ code: 'STORE_ROOT_MISMATCH' }) + expect(await Store.list({ root: cwd })).toEqual([]) + expect(await Store.list({ root: storageRoot })).toEqual([]) +}) const ownForm = [ 'name: Friction', 'body:', diff --git a/src/cli/commands/log.ts b/src/cli/commands/log.ts index cef5567..46b0050 100644 --- a/src/cli/commands/log.ts +++ b/src/cli/commands/log.ts @@ -117,6 +117,11 @@ export const log = Cli.create('log', { const { config, repo, root } = await context.resolve({ cwd: c.options.cwd }) const store = c.var.store ?? Store.file({ root }) const interactive = prompt.interactive() + if (store.name === 'file' && store.root !== undefined && store.root !== root) + return c.error({ + code: 'STORE_ROOT_MISMATCH', + message: 'The injected file store must use the same root as `--cwd`.', + }) if (c.options.publish && store.name !== 'file') return c.error({ code: 'STORE_UNSUPPORTED_OPTION', diff --git a/test/postgres.ts b/test/postgres.ts index 1704815..7d3c732 100644 --- a/test/postgres.ts +++ b/test/postgres.ts @@ -1,6 +1,6 @@ import { randomUUID } from 'node:crypto' import { PostgreSqlContainer, type StartedPostgreSqlContainer } from '@testcontainers/postgresql' -import { Pool } from 'pg' +import postgresjs from 'postgres' import * as Store from '../src/Store.js' const image = 'postgres:18-alpine' @@ -8,13 +8,13 @@ const image = 'postgres:18-alpine' /** Starts one isolated Postgres container for the importing test file. */ export function get(): get.ReturnType { let container: StartedPostgreSqlContainer | undefined - let client: Pool | undefined + let client: postgresjs.Sql | undefined let connectionString: string | undefined beforeAll(async () => { container = await new PostgreSqlContainer(image).start() connectionString = container.getConnectionUri() - client = new Pool({ connectionString }) + client = postgresjs(connectionString) }, 120_000) afterAll(async () => { @@ -68,8 +68,8 @@ export declare namespace get { /** Container-backed Postgres test fixture. */ type ReturnType = { - /** Returns the connected pool after the test hook starts the container. */ - readonly client: () => Pool + /** Returns the connected Postgres.js client after the test hook starts the container. */ + readonly client: () => postgresjs.Sql /** Returns the container connection string after the test hook starts the container. */ readonly connectionString: () => string /** Creates an unmigrated store with an isolated namespace. */ From 0c570d2d3007dc321e11af293a77f089e565ce38 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Tue, 4 Aug 2026 13:59:44 +1000 Subject: [PATCH 16/26] fix: preserve store edge cases --- src/Entry.test.ts | 6 ++++++ src/Entry.ts | 4 +--- src/Store.postgres.test.ts | 23 +++++++++++++++++++++++ src/Store.ts | 16 ++++++++++++---- 4 files changed, 42 insertions(+), 7 deletions(-) diff --git a/src/Entry.test.ts b/src/Entry.test.ts index 6d0d87e..a2658d6 100644 --- a/src/Entry.test.ts +++ b/src/Entry.test.ts @@ -164,6 +164,12 @@ describe('round trip', () => { { body: 'Body.', severity: 'minor', title: '@scope/pkg: 100% broken #1 @ 3:00' }, { body: 'Body.', severity: 'minor', title: 'no: yes, true, null, ~, 0x1' }, { body: 'Body.', severity: 'minor', title: 'emoji 🎉 and — dashes' }, + { + body: 'Body.', + context: {}, + severity: 'minor', + title: 'empty context', + }, { body: 'Body.', context: { diff --git a/src/Entry.ts b/src/Entry.ts index 4dd3ce9..5fed053 100644 --- a/src/Entry.ts +++ b/src/Entry.ts @@ -157,9 +157,7 @@ export function serialize(entry: serialize.Options): string { { title, severity, - ...(normalizedContext && Object.keys(normalizedContext).length - ? { context: normalizedContext } - : {}), + ...(normalizedContext === undefined ? {} : { context: normalizedContext }), ...(target ? { target } : {}), ...(labels?.length ? { labels } : {}), ...(issue ? { issue } : {}), diff --git a/src/Store.postgres.test.ts b/src/Store.postgres.test.ts index 056ba9c..e268225 100644 --- a/src/Store.postgres.test.ts +++ b/src/Store.postgres.test.ts @@ -80,6 +80,20 @@ describe('postgres', () => { expect(repeated.entry.id).toBe(first.entry.id) }) + test('behavior: colliding title edits preserve independent entry identity', async () => { + const store = await postgres.store() + const frog = Frog.create({ store }) + const first = await frog.log(friction) + const second = await frog.log({ ...friction, title: 'Different friction' }) + + await store.write({ ...friction, title: 'Different friction' }, { id: first.entry.id }) + const repeated = await frog.log({ ...friction, title: 'different friction!' }) + + expect(await store.get(first.entry.id)).toMatchObject({ title: 'Different friction' }) + expect(repeated).toMatchObject({ created: false, occurrences: 2 }) + expect(repeated.entry.id).toBe(second.entry.id) + }) + test('behavior: namespaces isolate consumers and force preserves intentional duplicates', async () => { const first = Frog.create({ store: postgres.create({ namespace: 'one' }) }) const second = Frog.create({ store: postgres.create({ namespace: 'two' }) }) @@ -124,6 +138,15 @@ describe('postgres', () => { ).toThrow('Postgres schema must be a SQL identifier.') }) + test('error: rejects schema names PostgreSQL would truncate', () => { + expect(() => + Store.postgres({ + connectionString: postgres.connectionString(), + schema: `s${'a'.repeat(63)}`, + }), + ).toThrow('Postgres schema must be at most 63 bytes.') + }) + test('behavior: consumer context round trips without Frog interpreting it', () => { const serialized = Entry.serialize(friction) expect(Entry.parse(serialized, { id: 'one' }).context).toEqual(friction.context) diff --git a/src/Store.ts b/src/Store.ts index ca73730..fa566f3 100644 --- a/src/Store.ts +++ b/src/Store.ts @@ -401,8 +401,8 @@ type PostgresRow = { export function postgres(options: postgres.Options): Store { const connectionString = required(options.connectionString, 'connectionString') const namespace = required(options.namespace ?? 'default', 'namespace') - const sql = postgresjs(connectionString) const schema = options.schema === undefined ? undefined : schemaName(options.schema) + const sql = postgresjs(connectionString) const table = schema === undefined ? sql('frog_entries') : sql`${sql(schema)}.${sql('frog_entries')}` let closing: Promise | undefined @@ -472,12 +472,19 @@ export function postgres(options: postgres.Options): Store { const dedupeKey = `entry:${id}` const titleKey = `title:${Entry.normalizeTitle(entry.title)}` await sql` - INSERT INTO ${table}(namespace, id, dedupe_key, contents) + INSERT INTO ${table} AS existing(namespace, id, dedupe_key, contents) VALUES (${namespace}, ${id}, ${dedupeKey}, ${Entry.serialize(entry)}) ON CONFLICT(namespace, id) DO UPDATE SET dedupe_key = CASE - WHEN ${table}.dedupe_key LIKE 'title:%' THEN ${titleKey} - ELSE ${table}.dedupe_key + WHEN existing.dedupe_key LIKE 'title:%' AND NOT EXISTS ( + SELECT 1 + FROM ${table} AS duplicate + WHERE duplicate.namespace = ${namespace} + AND duplicate.dedupe_key = ${titleKey} + AND duplicate.id <> ${id} + ) THEN ${titleKey} + WHEN existing.dedupe_key LIKE 'title:%' THEN ${dedupeKey} + ELSE existing.dedupe_key END, contents = EXCLUDED.contents, updated_at = now() @@ -534,6 +541,7 @@ function newPostgresId(title: string): string { function schemaName(schema: string): string { if (!/^[a-z_][a-z0-9_]*$/i.test(schema)) throw new Error('Postgres schema must be a SQL identifier.') + if (schema.length > 63) throw new Error('Postgres schema must be at most 63 bytes.') return schema } From 2b1a59f8867702cc051b4a8ab37dcd75b2f6bc54 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:05:43 +1000 Subject: [PATCH 17/26] feat: migrate stores through frog --- .changeset/calm-frogs-store.md | 1 - README.md | 4 ++-- src/Frog.ts | 22 +++++++++++++++++++--- src/Store.postgres.test.ts | 10 ++++++++++ 4 files changed, 31 insertions(+), 6 deletions(-) diff --git a/.changeset/calm-frogs-store.md b/.changeset/calm-frogs-store.md index ab4ee9f..bf7c71f 100644 --- a/.changeset/calm-frogs-store.md +++ b/.changeset/calm-frogs-store.md @@ -8,7 +8,6 @@ Added pluggable friction stores, a Postgres.js-backed `Store.postgres({ connecti import { Frog, Store } from 'frog' const store = Store.postgres({ connectionString: process.env.DATABASE_URL! }) -await store.migrate() const frog = Frog.create({ store }) await frog.log({ body: 'The workaround used.', diff --git a/README.md b/README.md index d839f9e..830817b 100644 --- a/README.md +++ b/README.md @@ -195,13 +195,13 @@ consumers in one database (it defaults to `default`), and `FROG_SCHEMA` can place the table in a specific schema. An unrelated application `DATABASE_URL` does not change Frog's default store. -Applications use the same store through the programmatic API: +Applications use the same store through the programmatic API. Frog runs the idempotent migration +before its first store operation: ```ts import { Frog, Store } from 'frog' const store = Store.postgres({ connectionString: process.env.DATABASE_URL! }) -await store.migrate() const frog = Frog.create({ store }) const result = await frog.log({ diff --git a/src/Frog.ts b/src/Frog.ts index d8bd808..456a011 100644 --- a/src/Frog.ts +++ b/src/Frog.ts @@ -14,13 +14,29 @@ export type Frog = { readonly logs: () => Promise } -/** Creates a friction logger around one explicitly constructed store. */ +/** Creates a friction logger that prepares its store before the first operation. */ export function create(options: create.Options): Frog { const store = options.store + let migrated: Promise | undefined + const migrate = () => { + if (migrated) return migrated + const current = Promise.resolve().then(() => store.migrate()) + migrated = current.catch((error) => { + migrated = undefined + throw error + }) + return migrated + } return { store, - log: (entry, logOptions = {}) => log(store, entry, logOptions), - logs: () => store.records(), + async log(entry, logOptions = {}) { + await migrate() + return log(store, entry, logOptions) + }, + async logs() { + await migrate() + return store.records() + }, } } diff --git a/src/Store.postgres.test.ts b/src/Store.postgres.test.ts index e268225..1d35dee 100644 --- a/src/Store.postgres.test.ts +++ b/src/Store.postgres.test.ts @@ -14,6 +14,16 @@ const friction = { const postgres = Postgres.get() describe('postgres', () => { + test('behavior: Frog prepares storage before its first operation', async () => { + const logStore = postgres.create({ schema: 'frog_create_log' }) + const logsStore = postgres.create({ schema: 'frog_create_logs' }) + + await expect(Frog.create({ store: logStore }).log(friction)).resolves.toMatchObject({ + created: true, + }) + await expect(Frog.create({ store: logsStore }).logs()).resolves.toEqual([]) + }) + test('behavior: migration creates the configured schema and is idempotent', async () => { const client = postgres.client() const store = postgres.create({ namespace: 'consumer-a', schema: 'frog' }) From 71283ea7fc2fa0f482abc3bc345fbbf9bdf95f72 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:09:38 +1000 Subject: [PATCH 18/26] docs: describe database store roadmap --- README.md | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 830817b..d87d731 100644 --- a/README.md +++ b/README.md @@ -180,9 +180,18 @@ ships a reproduction. Exits 1 on an entry that fails to parse, so it doubles as frog list ``` -### Store Logs in Postgres +### Store Logs in a Database -Frog stores entries in `.agents/friction-log/` by default. Set `FROG_DATABASE_URL` to use Postgres +Frog provides an isomorphic storage API: application code uses the same `Frog` and `Store` contracts +across database backends. PostgreSQL is the first built-in database, with more on the way. + +| Database | Status | Factory | +| ---------- | ----------- | -------------------------------------- | +| PostgreSQL | Available | `Store.postgres({ connectionString })` | +| SQLite | Coming soon | — | +| MySQL | Coming soon | — | + +Frog stores entries in `.agents/friction-log/` by default. Set `FROG_DATABASE_URL` to use PostgreSQL instead, then run the idempotent migration once: ```sh @@ -215,9 +224,9 @@ const unresolved = await frog.logs() // canonical entries with deduplicated occu await store.close() ``` -Every store implements `Store.Store` and preserves the same `Entry` fields. Use `Store.from` to adapt a -remote service, SQLite database, or another backend. Storage metadata such as occurrence counts stays -outside the entry schema. Repository and GitHub automation—artifacts, `list --since`, +Every store implements `Store.Store` and preserves the same `Entry` fields. Use `Store.from` to adapt an +unsupported database or remote service today. Storage metadata such as occurrence counts stays outside +the entry schema. Repository and GitHub automation—artifacts, `list --since`, `log --open`, `log --publish`, `publish`, and `sync`—remains available only with the file store. ### Logging Upstream From 74b1d55db68cc5050dbf25bf1b55a45766d3fae8 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:12:22 +1000 Subject: [PATCH 19/26] docs: add d1 store roadmap --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index d87d731..2ca5e99 100644 --- a/README.md +++ b/README.md @@ -185,11 +185,11 @@ frog list Frog provides an isomorphic storage API: application code uses the same `Frog` and `Store` contracts across database backends. PostgreSQL is the first built-in database, with more on the way. -| Database | Status | Factory | -| ---------- | ----------- | -------------------------------------- | -| PostgreSQL | Available | `Store.postgres({ connectionString })` | -| SQLite | Coming soon | — | -| MySQL | Coming soon | — | +| Database | Status | Factory | +| ------------- | ----------- | -------------------------------------- | +| PostgreSQL | Available | `Store.postgres({ connectionString })` | +| SQLite | Coming soon | — | +| Cloudflare D1 | Coming soon | — | Frog stores entries in `.agents/friction-log/` by default. Set `FROG_DATABASE_URL` to use PostgreSQL instead, then run the idempotent migration once: From 6b5f93ceefed745c0adb621a315d772cca65d8f2 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:30:09 +1000 Subject: [PATCH 20/26] docs: reorder database store guide --- README.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 2ca5e99..e23b43a 100644 --- a/README.md +++ b/README.md @@ -185,11 +185,7 @@ frog list Frog provides an isomorphic storage API: application code uses the same `Frog` and `Store` contracts across database backends. PostgreSQL is the first built-in database, with more on the way. -| Database | Status | Factory | -| ------------- | ----------- | -------------------------------------- | -| PostgreSQL | Available | `Store.postgres({ connectionString })` | -| SQLite | Coming soon | — | -| Cloudflare D1 | Coming soon | — | +#### Usage Frog stores entries in `.agents/friction-log/` by default. Set `FROG_DATABASE_URL` to use PostgreSQL instead, then run the idempotent migration once: @@ -224,6 +220,14 @@ const unresolved = await frog.logs() // canonical entries with deduplicated occu await store.close() ``` +#### Database Support + +| Database | Status | Factory | +| ------------- | ----------- | -------------------------------------- | +| PostgreSQL | Available | `Store.postgres({ connectionString })` | +| SQLite | Coming soon | — | +| Cloudflare D1 | Coming soon | — | + Every store implements `Store.Store` and preserves the same `Entry` fields. Use `Store.from` to adapt an unsupported database or remote service today. Storage metadata such as occurrence counts stays outside the entry schema. Repository and GitHub automation—artifacts, `list --since`, From 3aa2ac96f161d5637dc9167b4d26a6a588af9dfc Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:35:41 +1000 Subject: [PATCH 21/26] fix: guard store parsing boundaries --- src/Entry.test.ts | 10 ++++++++++ src/Entry.ts | 23 +++++++++++++++++++++-- src/cli/commands/sync.test.ts | 12 ++++++++++++ src/cli/commands/sync.ts | 5 +++++ 4 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/Entry.test.ts b/src/Entry.test.ts index a2658d6..48cd15f 100644 --- a/src/Entry.test.ts +++ b/src/Entry.test.ts @@ -87,6 +87,16 @@ The filter was swallowed. `[Entry.InvalidError: Entry \`lazy-squids-chew\` has invalid frontmatter. issue: Invalid string: must match pattern /^[\\w.-]+\\/[\\w.-]+#\\d+$/]`, ) }) + + test('error: rejects YAML aliases before validating recursive context', () => { + expect(() => + Entry.parse("---\ntitle: 'Slow'\ncontext: &context\n self: *context\n---\n\nBody.\n", { + id, + }), + ).toThrowErrorMatchingInlineSnapshot( + `[Entry.InvalidError: Entry \`lazy-squids-chew\` has invalid frontmatter. frontmatter: YAML aliases are not supported.]`, + ) + }) }) describe('serialize', () => { diff --git a/src/Entry.ts b/src/Entry.ts index 5fed053..f8adcaf 100644 --- a/src/Entry.ts +++ b/src/Entry.ts @@ -95,6 +95,8 @@ export type Entry = Frontmatter & { */ const frontmatterRegex = /\s*---([^]*?)\n\s*---(\s*(?:\n|$)[^]*)/ +const NoYamlAliases = z.custom(() => false, 'YAML aliases are not supported.') + /** * Parses an entry's write-up. * @@ -115,14 +117,31 @@ export function parse(contents: string, options: parse.Options): Entry { if (!match) throw new MalformedError({ id }) const [, frontmatter = '', body = ''] = match - const data = (() => { + const document = (() => { try { - return YAML.parse(frontmatter) + const parsed = YAML.parseDocument(frontmatter) + const error = parsed.errors[0] + if (error) throw error + return parsed } catch (error) { throw new MalformedError({ cause: error as Error, id }) } })() + let hasAlias = false + YAML.visit(document, { + Alias() { + hasAlias = true + return YAML.visit.BREAK + }, + }) + if (hasAlias) { + const result = NoYamlAliases.safeParse(undefined) + if (!result.success) throw new InvalidError({ id, issues: result.error.issues }) + } + + const data = document.toJS({ maxAliasCount: 0 }) + const result = Frontmatter.safeParse(data) if (!result.success) throw new InvalidError({ id, issues: result.error.issues }) diff --git a/src/cli/commands/sync.test.ts b/src/cli/commands/sync.test.ts index 3ba26ea..14dd611 100644 --- a/src/cli/commands/sync.test.ts +++ b/src/cli/commands/sync.test.ts @@ -43,6 +43,18 @@ test('error: repository reconciliation requires the file store', async () => { ) }) +test('error: an injected file store must match the command root', async () => { + const cwd = await helpers.repo({ remote }) + const storageRoot = await helpers.repo({ remote }) + const store = Store.file({ root: storageRoot }) + + expect(await cli.error(['sync', '--cwd', cwd], {}, { store })).toMatchObject({ + code: 'STORE_ROOT_MISMATCH', + }) + expect(await Store.list({ root: cwd })).toEqual([]) + expect(await Store.list({ root: storageRoot })).toEqual([]) +}) + function issueBody(id: string, body = 'Body.', severity?: Entry.Severity): string { return Github.renderBody({ body, diff --git a/src/cli/commands/sync.ts b/src/cli/commands/sync.ts index 53c6260..6268447 100644 --- a/src/cli/commands/sync.ts +++ b/src/cli/commands/sync.ts @@ -79,6 +79,11 @@ export const sync = Cli.create('sync', { message: '`sync` requires the repository file store because reconciliation mirrors are repository-owned.', }) + if (store.root !== root) + return c.error({ + code: 'STORE_ROOT_MISMATCH', + message: 'The injected file store must use the same root as `--cwd`.', + }) const entries = await attempt(store.read()) if (!entries.ok) return c.error({ code: entries.code, message: entries.message }) From 3a097a6520dbceb6966316c8cbb0352a0de6c3d3 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:37:25 +1000 Subject: [PATCH 22/26] docs: simplify database setup --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e23b43a..b26232b 100644 --- a/README.md +++ b/README.md @@ -188,7 +188,7 @@ across database backends. PostgreSQL is the first built-in database, with more o #### Usage Frog stores entries in `.agents/friction-log/` by default. Set `FROG_DATABASE_URL` to use PostgreSQL -instead, then run the idempotent migration once: +instead. ```sh FROG_DATABASE_URL=postgres://... frog migrate From 0239cebdd2fd96358ac0e9eece4126dd01efcd03 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:37:54 +1000 Subject: [PATCH 23/26] docs: trim database usage --- README.md | 25 ------------------------- 1 file changed, 25 deletions(-) diff --git a/README.md b/README.md index b26232b..acfe472 100644 --- a/README.md +++ b/README.md @@ -195,31 +195,6 @@ FROG_DATABASE_URL=postgres://... frog migrate FROG_DATABASE_URL=postgres://... frog list ``` -Frog includes Postgres.js and owns the store's connection pool. `FROG_NAMESPACE` can isolate several -consumers in one database (it defaults to `default`), and -`FROG_SCHEMA` can place the table in a specific schema. An unrelated application `DATABASE_URL` does -not change Frog's default store. - -Applications use the same store through the programmatic API. Frog runs the idempotent migration -before its first store operation: - -```ts -import { Frog, Store } from 'frog' - -const store = Store.postgres({ connectionString: process.env.DATABASE_URL! }) -const frog = Frog.create({ store }) - -const result = await frog.log({ - title: 'Search result omitted its freshness', - body: 'The caller could not tell when the result was collected.', - severity: 'major', - context: { source: 'production-agent', execution: 'opaque-reference' }, -}) - -const unresolved = await frog.logs() // canonical entries with deduplicated occurrence counts -await store.close() -``` - #### Database Support | Database | Status | Factory | From b70df9246d23d3b62891f9948848bb8b23bba34f Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:41:27 +1000 Subject: [PATCH 24/26] docs: reorder database storage --- README.md | 45 +++++++++++++++++---------------------------- 1 file changed, 17 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index acfe472..ffdbe0a 100644 --- a/README.md +++ b/README.md @@ -180,34 +180,6 @@ ships a reproduction. Exits 1 on an entry that fails to parse, so it doubles as frog list ``` -### Store Logs in a Database - -Frog provides an isomorphic storage API: application code uses the same `Frog` and `Store` contracts -across database backends. PostgreSQL is the first built-in database, with more on the way. - -#### Usage - -Frog stores entries in `.agents/friction-log/` by default. Set `FROG_DATABASE_URL` to use PostgreSQL -instead. - -```sh -FROG_DATABASE_URL=postgres://... frog migrate -FROG_DATABASE_URL=postgres://... frog list -``` - -#### Database Support - -| Database | Status | Factory | -| ------------- | ----------- | -------------------------------------- | -| PostgreSQL | Available | `Store.postgres({ connectionString })` | -| SQLite | Coming soon | — | -| Cloudflare D1 | Coming soon | — | - -Every store implements `Store.Store` and preserves the same `Entry` fields. Use `Store.from` to adapt an -unsupported database or remote service today. Storage metadata such as occurrence counts stays outside -the entry schema. Repository and GitHub automation—artifacts, `list --since`, -`log --open`, `log --publish`, `publish`, and `sync`—remains available only with the file store. - ### Logging Upstream Reports friction to another project instead of your own. A target is an npm package or an `owner/repo`, @@ -241,6 +213,23 @@ Accept friction reported by other repositories with `.agents/friction-log/config `frog init` enables inbound reports by default. Run `frog init --no-inbound` to initialize without accepting inbound reports. +### Store Logs in a Database + +Frog provides an isomorphic storage API. + +```sh +FROG_DATABASE_URL=postgres://... frog migrate +FROG_DATABASE_URL=postgres://... frog list +``` + +#### Database Support + +| Database | Status | Factory | +| ------------- | ----------- | -------------------------------------- | +| PostgreSQL | Available | `Store.postgres({ connectionString })` | +| SQLite | Coming soon | — | +| Cloudflare D1 | Coming soon | — | + ## CLI Reference ``` From 20ecbf05c30cf857071793788314da50627821f1 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:54:47 +1000 Subject: [PATCH 25/26] docs: expand database overview --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ffdbe0a..bdf516b 100644 --- a/README.md +++ b/README.md @@ -215,7 +215,9 @@ accepting inbound reports. ### Store Logs in a Database -Frog provides an isomorphic storage API. +Frog separates friction logging from persistence, so agents and applications can record and inspect +logs through one API while choosing where they live. Repository files are the default, PostgreSQL is +supported today, and SQLite and Cloudflare D1 are planned. ```sh FROG_DATABASE_URL=postgres://... frog migrate From ec4ce5a61ba54dc248acc0c3afc4b86545c6b435 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:55:09 +1000 Subject: [PATCH 26/26] docs: reuse database URL --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index bdf516b..31c3bc5 100644 --- a/README.md +++ b/README.md @@ -220,8 +220,9 @@ logs through one API while choosing where they live. Repository files are the de supported today, and SQLite and Cloudflare D1 are planned. ```sh -FROG_DATABASE_URL=postgres://... frog migrate -FROG_DATABASE_URL=postgres://... frog list +export FROG_DATABASE_URL=postgres://... +frog migrate +frog list ``` #### Database Support