From adb73d307eac657a1c9d5ce7c0316b1b8faf38fe Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Sun, 12 Apr 2026 23:02:12 -0400 Subject: [PATCH 01/15] =?UTF-8?q?feat(sdk):=20remove=20allowProtected=20?= =?UTF-8?q?=E2=80=94=20protected=20configs=20unconditionally=20block=20des?= =?UTF-8?q?tructive=20ops?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/sdk/guards.ts | 9 ++++---- src/sdk/types.ts | 9 -------- tests/cli/focus.test.tsx | 46 ++++++++++++++-------------------------- 3 files changed, 20 insertions(+), 44 deletions(-) diff --git a/src/sdk/guards.ts b/src/sdk/guards.ts index 5062a9ca..bce8f9e5 100644 --- a/src/sdk/guards.ts +++ b/src/sdk/guards.ts @@ -39,8 +39,8 @@ export class RequireTestError extends Error { * * @example * ```typescript - * // If config.protected is true and allowProtected is false - * await ctx.truncate() // Throws ProtectedConfigError + * // config.protected is true — all destructive ops are blocked + * await ctx.noorm.db.truncate() // Throws ProtectedConfigError * ``` */ export class ProtectedConfigError extends Error { @@ -83,15 +83,14 @@ export function checkRequireTest( /** * Check if operation is allowed on protected config. * - * @throws ProtectedConfigError if config is protected and allowProtected is false + * @throws ProtectedConfigError if config is protected */ export function checkProtectedConfig( config: Config, operation: string, - options: CreateContextOptions, ): void { - if (config.protected && !options.allowProtected) { + if (config.protected) { throw new ProtectedConfigError(config.name, operation); diff --git a/src/sdk/types.ts b/src/sdk/types.ts index e06cb360..8f318c22 100644 --- a/src/sdk/types.ts +++ b/src/sdk/types.ts @@ -23,12 +23,6 @@ * requireTest: true, * }) * - * // Allow destructive ops on protected config - * const ctx = await createContext({ - * config: 'staging', - * allowProtected: true, - * }) - * * // Env-only mode (CI/CD) - no stored config needed * // Requires NOORM_CONNECTION_DIALECT and NOORM_CONNECTION_DATABASE * const ctx = await createContext() @@ -56,9 +50,6 @@ export interface CreateContextOptions { /** Refuse if config.isTest !== true. Default: false */ requireTest?: boolean; - /** Allow destructive ops on protected configs. Default: false */ - allowProtected?: boolean; - /** Stage name for stage defaults (from settings.yml) */ stage?: string; diff --git a/tests/cli/focus.test.tsx b/tests/cli/focus.test.tsx index 3fbf2c1f..89946f32 100644 --- a/tests/cli/focus.test.tsx +++ b/tests/cli/focus.test.tsx @@ -5,23 +5,9 @@ */ import { describe, it, expect, vi } from 'bun:test'; import { render } from 'ink-testing-library'; -import React, { act, useEffect, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { Text } from 'ink'; -// React 19 requires this flag for act() to flush effects in non-browser environments -(globalThis as Record).IS_REACT_ACT_ENVIRONMENT = true; - -/** Flush all pending React effects and state updates */ -async function flushEffects(ms = 0) { - - await act(async () => { - - await new Promise((r) => setTimeout(r, ms)); - - }); - -} - import { FocusProvider, useFocusContext, @@ -149,7 +135,7 @@ describe('cli: focus', () => { , ); - await flushEffects(); + await new Promise((resolve) => setTimeout(resolve, 10)); expect(lastFrame()).toContain('activeId:test-1'); expect(lastFrame()).toContain('stackLen:1'); @@ -190,7 +176,7 @@ describe('cli: focus', () => { , ); - await flushEffects(); + await new Promise((resolve) => setTimeout(resolve, 10)); expect(lastFrame()).toContain('activeId:second'); expect(lastFrame()).toContain('stackLen:2'); @@ -232,7 +218,7 @@ describe('cli: focus', () => { , ); - await flushEffects(); + await new Promise((resolve) => setTimeout(resolve, 10)); expect(lastFrame()).toContain('stackLen:1'); @@ -268,7 +254,7 @@ describe('cli: focus', () => { , ); - await flushEffects(); + await new Promise((resolve) => setTimeout(resolve, 10)); expect(lastFrame()).toContain('labels:My Label'); @@ -286,7 +272,7 @@ describe('cli: focus', () => { , ); - await flushEffects(); + await new Promise((resolve) => setTimeout(resolve, 10)); expect(lastFrame()).toContain('activeId:null'); expect(lastFrame()).toContain('stackLen:0'); @@ -327,7 +313,7 @@ describe('cli: focus', () => { , ); - await flushEffects(); + await new Promise((resolve) => setTimeout(resolve, 10)); expect(lastFrame()).toContain('stackLen:1'); expect(lastFrame()).toContain('activeId:existing'); @@ -380,7 +366,7 @@ describe('cli: focus', () => { , ); - await flushEffects(100); + await new Promise((resolve) => setTimeout(resolve, 100)); expect(lastFrame()).toContain('stackLen:2'); expect(lastFrame()).toContain('stackIds:first,third'); @@ -416,7 +402,7 @@ describe('cli: focus', () => { , ); - await flushEffects(); + await new Promise((resolve) => setTimeout(resolve, 10)); expect(lastFrame()).toContain('isActive:true'); @@ -447,7 +433,7 @@ describe('cli: focus', () => { , ); - await flushEffects(); + await new Promise((resolve) => setTimeout(resolve, 10)); expect(lastFrame()).toContain('isActive:false'); @@ -521,12 +507,12 @@ describe('cli: focus', () => { ); // Initially mounted - wait for focus stack to initialize - await flushEffects(); + await new Promise((resolve) => setTimeout(resolve, 10)); expect(lastFrame()).toContain('focused:true'); expect(lastFrame()).toContain('stackLen:1'); // After unmount - await flushEffects(100); + await new Promise((resolve) => setTimeout(resolve, 100)); expect(lastFrame()).toContain('stackLen:0'); }); @@ -554,7 +540,7 @@ describe('cli: focus', () => { , ); - await flushEffects(); + await new Promise((resolve) => setTimeout(resolve, 10)); // Rerender should keep same ID rerender( @@ -563,7 +549,7 @@ describe('cli: focus', () => { , ); - await flushEffects(); + await new Promise((resolve) => setTimeout(resolve, 10)); // All collected IDs should be the same expect(ids.length).toBeGreaterThan(0); @@ -598,7 +584,7 @@ describe('cli: focus', () => { , ); - await flushEffects(); + await new Promise((resolve) => setTimeout(resolve, 10)); expect(lastFrame()).toContain('focused:true'); @@ -651,7 +637,7 @@ describe('cli: focus', () => { , ); - await flushEffects(); + await new Promise((resolve) => setTimeout(resolve, 10)); expect(lastFrame()).toContain('active:my-active'); From 9acfbfe64a5c8cd237041370e2ec4153e363642d Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Sun, 12 Apr 2026 23:02:57 -0400 Subject: [PATCH 02/15] fix(sdk): update checkProtectedConfig call sites in db namespace --- src/sdk/namespaces/db.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/sdk/namespaces/db.ts b/src/sdk/namespaces/db.ts index 8281c0de..d9455fb4 100644 --- a/src/sdk/namespaces/db.ts +++ b/src/sdk/namespaces/db.ts @@ -274,7 +274,7 @@ export class DbNamespace { */ async truncate(options?: TruncateOptions): Promise { - checkProtectedConfig(this.#state.config, 'truncate', this.#state.options); + checkProtectedConfig(this.#state.config, 'truncate'); const preserve = options?.preserve ?? this.#state.settings.teardown?.preserveTables; @@ -296,7 +296,7 @@ export class DbNamespace { */ async teardown(): Promise { - checkProtectedConfig(this.#state.config, 'teardown', this.#state.options); + checkProtectedConfig(this.#state.config, 'teardown'); return teardownSchema(this.#kysely, this.#dialect, { configName: this.#state.config.name, @@ -317,7 +317,7 @@ export class DbNamespace { */ async reset(): Promise { - checkProtectedConfig(this.#state.config, 'reset', this.#state.options); + checkProtectedConfig(this.#state.config, 'reset'); await this.teardown(); From c34419495e226fb5494f4417a5d5a6d891f64731 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Sun, 12 Apr 2026 23:04:10 -0400 Subject: [PATCH 03/15] fix(sdk): remove stale allowProtected JSDoc references --- src/sdk/index.ts | 6 ------ src/sdk/namespaces/db.ts | 2 +- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/src/sdk/index.ts b/src/sdk/index.ts index 757d63f1..b6b198ca 100644 --- a/src/sdk/index.ts +++ b/src/sdk/index.ts @@ -78,12 +78,6 @@ function toSettingsProvider(manager: SettingsManager): SettingsProvider { * requireTest: true, * }) * - * // Allow destructive ops on protected config - * const ctx = await createContext({ - * config: 'staging', - * allowProtected: true, - * }) - * * // Env-only mode (CI/CD) - no stored config needed * // Requires NOORM_CONNECTION_DIALECT and NOORM_CONNECTION_DATABASE * const ctx = await createContext() diff --git a/src/sdk/namespaces/db.ts b/src/sdk/namespaces/db.ts index d9455fb4..3fb84af3 100644 --- a/src/sdk/namespaces/db.ts +++ b/src/sdk/namespaces/db.ts @@ -2,7 +2,7 @@ * Db namespace — database exploration and schema operations. * * Mirrors [d] db in the TUI. All operations require a connection. - * Destructive operations respect the allowProtected guard. + * Destructive operations are unconditionally blocked on protected configs. */ import type { Kysely } from 'kysely'; From 0131b05080b2dd72f6f1af0839f0b03ac587aa86 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Sun, 12 Apr 2026 23:05:16 -0400 Subject: [PATCH 04/15] feat(sdk): block dt.importFile on protected configs --- src/sdk/namespaces/dt.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/sdk/namespaces/dt.ts b/src/sdk/namespaces/dt.ts index 7ab21178..1ce745a8 100644 --- a/src/sdk/namespaces/dt.ts +++ b/src/sdk/namespaces/dt.ts @@ -10,6 +10,7 @@ import { exportTable as coreExportTable, importDtFile } from '../../core/dt/inde import type { ContextState } from '../state.js'; import type { ExportOptions, ImportOptions } from '../types.js'; +import { checkProtectedConfig } from '../guards.js'; // ───────────────────────────────────────────────────────────── // DtNamespace @@ -66,6 +67,8 @@ export class DtNamespace { options?: ImportOptions, ): Promise<[{ rowsImported: number; rowsSkipped: number } | null, Error | null]> { + checkProtectedConfig(this.#state.config, 'dt.import'); + return importDtFile({ filepath, db: this.#kysely, From 6c2324a6843b738b48a5a2dc716514d63268595d Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Sun, 12 Apr 2026 23:05:23 -0400 Subject: [PATCH 05/15] feat(sdk): block changes.revert on protected configs --- src/sdk/namespaces/changes.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/sdk/namespaces/changes.ts b/src/sdk/namespaces/changes.ts index a6f6d465..931046fa 100644 --- a/src/sdk/namespaces/changes.ts +++ b/src/sdk/namespaces/changes.ts @@ -34,6 +34,7 @@ import { ChangeManager, } from '../../core/change/index.js'; import { getStateManager } from '../../core/state/index.js'; +import { checkProtectedConfig } from '../guards.js'; import type { ContextState } from '../state.js'; @@ -238,6 +239,8 @@ export class ChangesNamespace { */ async revert(name: string, options?: ChangeOptions): Promise { + checkProtectedConfig(this.#state.config, 'changes.revert'); + return this.#getManager().revert(name, options); } @@ -351,6 +354,8 @@ export class ChangesNamespace { */ async rewind(target: number | string): Promise { + checkProtectedConfig(this.#state.config, 'changes.revert'); + return this.#getManager().rewind(target); } From e5096e811569dcf39e0c35f4208e6f8f09ae80f0 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Sun, 12 Apr 2026 23:09:15 -0400 Subject: [PATCH 06/15] test(sdk): add guard unit tests for requireTest and protected config --- tests/sdk/guards.test.ts | 192 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 tests/sdk/guards.test.ts diff --git a/tests/sdk/guards.test.ts b/tests/sdk/guards.test.ts new file mode 100644 index 00000000..584eb780 --- /dev/null +++ b/tests/sdk/guards.test.ts @@ -0,0 +1,192 @@ +import { describe, it, expect } from 'bun:test'; +import { + checkRequireTest, + checkProtectedConfig, + RequireTestError, + ProtectedConfigError, +} from '../../src/sdk/guards.js'; +import type { Config } from '../../src/core/config/types.js'; +import type { CreateContextOptions } from '../../src/sdk/types.js'; + +function makeConfig(overrides: Partial = {}): Config { + + return { + name: 'test', + type: 'local', + isTest: true, + protected: false, + connection: { dialect: 'postgres', database: 'testdb' }, + ...overrides, + } as Config; + +} + +describe('checkRequireTest', () => { + + it('does not throw when requireTest is false and isTest is false', () => { + + const config = makeConfig({ isTest: false }); + const options: CreateContextOptions = { requireTest: false }; + + expect(() => checkRequireTest(config, options)).not.toThrow(); + + }); + + it('does not throw when requireTest is true and isTest is true', () => { + + const config = makeConfig({ isTest: true }); + const options: CreateContextOptions = { requireTest: true }; + + expect(() => checkRequireTest(config, options)).not.toThrow(); + + }); + + it('throws RequireTestError when requireTest is true and isTest is false', () => { + + const config = makeConfig({ isTest: false, name: 'prod' }); + const options: CreateContextOptions = { requireTest: true }; + + expect(() => checkRequireTest(config, options)).toThrow(RequireTestError); + + }); + + it('carries config name in RequireTestError', () => { + + const config = makeConfig({ isTest: false, name: 'prod' }); + const options: CreateContextOptions = { requireTest: true }; + + try { + + checkRequireTest(config, options); + + } + catch (err) { + + if (err instanceof RequireTestError) { + + expect(err.configName).toBe('prod'); + expect(err.message).toContain('prod'); + + } + + } + + }); + + it('does not throw when requireTest is omitted from options', () => { + + const config = makeConfig({ isTest: false }); + const options: CreateContextOptions = {}; + + expect(() => checkRequireTest(config, options)).not.toThrow(); + + }); + +}); + +describe('checkProtectedConfig', () => { + + it('does not throw on non-protected config', () => { + + const config = makeConfig({ protected: false }); + const operation = 'truncate'; + + expect(() => checkProtectedConfig(config, operation)).not.toThrow(); + + }); + + it('throws ProtectedConfigError when protected is true', () => { + + const config = makeConfig({ protected: true }); + const operation = 'truncate'; + + expect(() => checkProtectedConfig(config, operation)).toThrow(ProtectedConfigError); + + }); + + it('carries configName in ProtectedConfigError', () => { + + const config = makeConfig({ protected: true, name: 'prod' }); + const operation = 'truncate'; + + try { + + checkProtectedConfig(config, operation); + + } + catch (err) { + + if (err instanceof ProtectedConfigError) { + + expect(err.configName).toBe('prod'); + + } + + } + + }); + + it('carries operation in ProtectedConfigError', () => { + + const config = makeConfig({ protected: true, name: 'prod' }); + const operation = 'truncate'; + + try { + + checkProtectedConfig(config, operation); + + } + catch (err) { + + if (err instanceof ProtectedConfigError) { + + expect(err.operation).toBe('truncate'); + expect(err.message).toContain('truncate'); + + } + + } + + }); + + it('blocks truncate operation', () => { + + const config = makeConfig({ protected: true }); + + expect(() => checkProtectedConfig(config, 'truncate')).toThrow(ProtectedConfigError); + + }); + + it('blocks teardown operation', () => { + + const config = makeConfig({ protected: true }); + + expect(() => checkProtectedConfig(config, 'teardown')).toThrow(ProtectedConfigError); + + }); + + it('blocks reset operation', () => { + + const config = makeConfig({ protected: true }); + + expect(() => checkProtectedConfig(config, 'reset')).toThrow(ProtectedConfigError); + + }); + + it('blocks dt.import operation', () => { + + const config = makeConfig({ protected: true }); + + expect(() => checkProtectedConfig(config, 'dt.import')).toThrow(ProtectedConfigError); + + }); + + it('blocks changes.revert operation', () => { + + const config = makeConfig({ protected: true }); + + expect(() => checkProtectedConfig(config, 'changes.revert')).toThrow(ProtectedConfigError); + + }); + +}); From ee85cbb3804192988aa311c6e81a4fc1d88b8e80 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Sun, 12 Apr 2026 23:10:32 -0400 Subject: [PATCH 07/15] test(sdk): add destructive-ops tests proving protected guard blocks all destructive operations --- tests/sdk/destructive-ops.test.ts | 195 ++++++++++++++++++++++++++++++ tests/sdk/lifecycle.test.ts | 121 ++++++++++++++++++ 2 files changed, 316 insertions(+) create mode 100644 tests/sdk/destructive-ops.test.ts create mode 100644 tests/sdk/lifecycle.test.ts diff --git a/tests/sdk/destructive-ops.test.ts b/tests/sdk/destructive-ops.test.ts new file mode 100644 index 00000000..af7eb350 --- /dev/null +++ b/tests/sdk/destructive-ops.test.ts @@ -0,0 +1,195 @@ +/** + * SDK destructive-ops guard tests. + * + * Proves that config.protected = true unconditionally blocks every + * destructive operation across DbNamespace, DtNamespace, and + * ChangesNamespace, and that the same operations are NOT blocked on + * an unprotected config (they may fail for other reasons, but not with + * ProtectedConfigError). Read-only ops are never blocked regardless of + * the protected flag. + */ +import { describe, it, expect } from 'bun:test'; + +import { DbNamespace } from '../../src/sdk/namespaces/db.js'; +import { DtNamespace } from '../../src/sdk/namespaces/dt.js'; +import { ChangesNamespace } from '../../src/sdk/namespaces/changes.js'; +import { ProtectedConfigError } from '../../src/sdk/guards.js'; + +import type { ContextState } from '../../src/sdk/state.js'; +import type { Config } from '../../src/core/config/types.js'; +import type { Settings } from '../../src/core/settings/types.js'; +import type { Identity } from '../../src/core/identity/types.js'; + +// ───────────────────────────────────────────────────────────── +// Fixtures +// ───────────────────────────────────────────────────────────── + +function makeConfig(isProtected: boolean): Config { + + return { + name: isProtected ? 'prod' : 'dev', + type: 'local', + isTest: false, + protected: isProtected, + connection: { dialect: 'postgres', database: 'testdb' }, + }; + +} + +function makeState(isProtected: boolean): ContextState { + + return { + connection: null, + config: makeConfig(isProtected), + settings: {} as Settings, + identity: {} as Identity, + options: {}, + projectRoot: '/tmp', + changeManager: null, + }; + +} + +// ───────────────────────────────────────────────────────────── +// Tests +// ───────────────────────────────────────────────────────────── + +describe('sdk: protected config guard', () => { + + // ───────────────────────────────────────────────────── + // DbNamespace — protected blocks all destructive ops + // ───────────────────────────────────────────────────── + + describe('DbNamespace on protected config', () => { + + it('should throw ProtectedConfigError for truncate()', async () => { + + const db = new DbNamespace(makeState(true)); + + await expect(db.truncate()).rejects.toThrow(ProtectedConfigError); + + }); + + it('should throw ProtectedConfigError for teardown()', async () => { + + const db = new DbNamespace(makeState(true)); + + await expect(db.teardown()).rejects.toThrow(ProtectedConfigError); + + }); + + it('should throw ProtectedConfigError for reset()', async () => { + + const db = new DbNamespace(makeState(true)); + + await expect(db.reset()).rejects.toThrow(ProtectedConfigError); + + }); + + }); + + // ───────────────────────────────────────────────────── + // DtNamespace — protected blocks importFile + // ───────────────────────────────────────────────────── + + describe('DtNamespace on protected config', () => { + + it('should throw ProtectedConfigError for importFile()', async () => { + + const dt = new DtNamespace(makeState(true)); + + await expect(dt.importFile('./fake.dtz')).rejects.toThrow(ProtectedConfigError); + + }); + + }); + + // ───────────────────────────────────────────────────── + // ChangesNamespace — protected blocks revert and rewind + // ───────────────────────────────────────────────────── + + describe('ChangesNamespace on protected config', () => { + + it('should throw ProtectedConfigError for revert()', async () => { + + const changes = new ChangesNamespace(makeState(true)); + + await expect(changes.revert('any-change')).rejects.toThrow(ProtectedConfigError); + + }); + + it('should throw ProtectedConfigError for rewind()', async () => { + + const changes = new ChangesNamespace(makeState(true)); + + await expect(changes.rewind('any-change')).rejects.toThrow(ProtectedConfigError); + + }); + + }); + + // ───────────────────────────────────────────────────── + // Unprotected config — guard does NOT block + // ───────────────────────────────────────────────────── + + describe('DbNamespace on unprotected config', () => { + + it('should not throw ProtectedConfigError for truncate()', async () => { + + const db = new DbNamespace(makeState(false)); + + const err = await db.truncate().catch((e: unknown) => e); + + expect(err).not.toBeInstanceOf(ProtectedConfigError); + + }); + + }); + + describe('DtNamespace on unprotected config', () => { + + it('should not throw ProtectedConfigError for importFile()', async () => { + + const dt = new DtNamespace(makeState(false)); + + const err = await dt.importFile('./fake.dtz').catch((e: unknown) => e); + + expect(err).not.toBeInstanceOf(ProtectedConfigError); + + }); + + }); + + describe('ChangesNamespace on unprotected config', () => { + + it('should not throw ProtectedConfigError for revert()', async () => { + + const changes = new ChangesNamespace(makeState(false)); + + const err = await changes.revert('any-change').catch((e: unknown) => e); + + expect(err).not.toBeInstanceOf(ProtectedConfigError); + + }); + + }); + + // ───────────────────────────────────────────────────── + // Read-only ops — never blocked by protected flag + // ───────────────────────────────────────────────────── + + describe('DtNamespace read-only ops on protected config', () => { + + it('should not throw ProtectedConfigError for exportTable()', async () => { + + const dt = new DtNamespace(makeState(true)); + + const err = await dt.exportTable('users', './fake.dtz').catch((e: unknown) => e); + + expect(err).not.toBeInstanceOf(ProtectedConfigError); + + }); + + }); + +}); diff --git a/tests/sdk/lifecycle.test.ts b/tests/sdk/lifecycle.test.ts new file mode 100644 index 00000000..15146201 --- /dev/null +++ b/tests/sdk/lifecycle.test.ts @@ -0,0 +1,121 @@ +/** + * SDK createContext lifecycle tests. + * + * Tests factory behavior and pre-connect guards. + * No ctx.connect() calls — no real DB required. + */ +import { describe, it, expect, afterEach } from 'bun:test'; +import { attempt } from '@logosdx/utils'; + +import { createContext, RequireTestError } from '../../src/sdk/index.js'; + +// ───────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────── + +/** + * Set env vars for env-only mode and return a cleanup function. + * + * NOORM_CONNECTION_DIALECT + NOORM_CONNECTION_DATABASE are the + * minimum required for resolveConfig() to return a config without + * a stored state file. + */ +function setEnvOnlyVars(dialect = 'postgres', database = 'testdb') { + + process.env.NOORM_CONNECTION_DIALECT = dialect; + process.env.NOORM_CONNECTION_DATABASE = database; + +} + +function clearEnvOnlyVars() { + + delete process.env.NOORM_CONNECTION_DIALECT; + delete process.env.NOORM_CONNECTION_DATABASE; + delete process.env.NOORM_CONFIG; + +} + +afterEach(() => { + + clearEnvOnlyVars(); + +}); + +// ───────────────────────────────────────────────────────────── +// createContext factory +// ───────────────────────────────────────────────────────────── + +describe('sdk: createContext factory', () => { + + it('rejects with "not found" when config name does not exist', async () => { + + const [, err] = await attempt(() => createContext({ config: '__nonexistent__' })); + + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toContain('not found'); + + }); + + it('returns an unconnected context in env-only mode', async () => { + + setEnvOnlyVars(); + + const ctx = await createContext(); + + expect(ctx.connected).toBe(false); + + }); + + it('rejects with RequireTestError when requireTest is true and env-only config has isTest: false', async () => { + + // Env-only configs default to isTest: false (resolver.ts:68). + // Pairing with requireTest: true exercises the guard. + setEnvOnlyVars(); + + const [, err] = await attempt(() => + createContext({ requireTest: true }), + ); + + expect(err).toBeInstanceOf(RequireTestError); + + }); + + it('never opens the connection pool when requireTest check fails', async () => { + + // The pool only opens inside ctx.connect(), which is called by the + // caller after createContext() succeeds. When createContext() throws + // (RequireTestError in this case), no Context is returned and therefore + // connect() is never called — the pool never opens. + // This test proves the rejection occurs before any Context is returned. + setEnvOnlyVars(); + + const [ctx, err] = await attempt(() => + createContext({ requireTest: true }), + ); + + expect(err).toBeInstanceOf(RequireTestError); + // attempt() returns null (not a Context) when the factory throws, + // confirming no Context was ever constructed and no pool was opened. + expect(ctx).toBeNull(); + + }); + +}); + +// ───────────────────────────────────────────────────────────── +// Context pre-connect behavior +// ───────────────────────────────────────────────────────────── + +describe('sdk: Context pre-connect behavior', () => { + + it('throws "Not connected" when accessing ctx.kysely before connect()', async () => { + + setEnvOnlyVars(); + + const ctx = await createContext(); + + expect(() => ctx.kysely).toThrow('Not connected'); + + }); + +}); From c9019eac217850d185dce223abc60f93a7eb9cb2 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Sun, 12 Apr 2026 23:12:27 -0400 Subject: [PATCH 08/15] fix(test): add expect.assertions to guard error-property tests, remove as Config cast --- tests/sdk/guards.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/sdk/guards.test.ts b/tests/sdk/guards.test.ts index 584eb780..230cff95 100644 --- a/tests/sdk/guards.test.ts +++ b/tests/sdk/guards.test.ts @@ -17,7 +17,7 @@ function makeConfig(overrides: Partial = {}): Config { protected: false, connection: { dialect: 'postgres', database: 'testdb' }, ...overrides, - } as Config; + }; } @@ -55,6 +55,8 @@ describe('checkRequireTest', () => { const config = makeConfig({ isTest: false, name: 'prod' }); const options: CreateContextOptions = { requireTest: true }; + expect.assertions(2); + try { checkRequireTest(config, options); @@ -109,6 +111,8 @@ describe('checkProtectedConfig', () => { const config = makeConfig({ protected: true, name: 'prod' }); const operation = 'truncate'; + expect.assertions(1); + try { checkProtectedConfig(config, operation); @@ -131,6 +135,8 @@ describe('checkProtectedConfig', () => { const config = makeConfig({ protected: true, name: 'prod' }); const operation = 'truncate'; + expect.assertions(2); + try { checkProtectedConfig(config, operation); From 87bcfd7dba3e8df9abb15dc14270e7b51e2d1ac7 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Sun, 12 Apr 2026 23:13:36 -0400 Subject: [PATCH 09/15] fix(test): replace try/catch with attemptSync in guard error-property tests --- tests/sdk/guards.test.ts | 44 +++++++++++----------------------------- 1 file changed, 12 insertions(+), 32 deletions(-) diff --git a/tests/sdk/guards.test.ts b/tests/sdk/guards.test.ts index 230cff95..5d839658 100644 --- a/tests/sdk/guards.test.ts +++ b/tests/sdk/guards.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from 'bun:test'; +import { attemptSync } from '@logosdx/utils'; import { checkRequireTest, checkProtectedConfig, @@ -57,19 +58,12 @@ describe('checkRequireTest', () => { expect.assertions(2); - try { + const [, err] = attemptSync(() => checkRequireTest(config, options)); - checkRequireTest(config, options); + if (err instanceof RequireTestError) { - } - catch (err) { - - if (err instanceof RequireTestError) { - - expect(err.configName).toBe('prod'); - expect(err.message).toContain('prod'); - - } + expect(err.configName).toBe('prod'); + expect(err.message).toContain('prod'); } @@ -113,18 +107,11 @@ describe('checkProtectedConfig', () => { expect.assertions(1); - try { - - checkProtectedConfig(config, operation); - - } - catch (err) { - - if (err instanceof ProtectedConfigError) { + const [, err] = attemptSync(() => checkProtectedConfig(config, operation)); - expect(err.configName).toBe('prod'); + if (err instanceof ProtectedConfigError) { - } + expect(err.configName).toBe('prod'); } @@ -137,19 +124,12 @@ describe('checkProtectedConfig', () => { expect.assertions(2); - try { - - checkProtectedConfig(config, operation); - - } - catch (err) { - - if (err instanceof ProtectedConfigError) { + const [, err] = attemptSync(() => checkProtectedConfig(config, operation)); - expect(err.operation).toBe('truncate'); - expect(err.message).toContain('truncate'); + if (err instanceof ProtectedConfigError) { - } + expect(err.operation).toBe('truncate'); + expect(err.message).toContain('truncate'); } From a9c125cae9b1ac0424b1939aed6dc3d0cbbeb4ba Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Sun, 12 Apr 2026 23:14:59 -0400 Subject: [PATCH 10/15] fix(test): remove as-casts from lifecycle and destructive-ops test fixtures --- tests/sdk/destructive-ops.test.ts | 7 +++++-- tests/sdk/lifecycle.test.ts | 7 ++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/tests/sdk/destructive-ops.test.ts b/tests/sdk/destructive-ops.test.ts index af7eb350..cabb6400 100644 --- a/tests/sdk/destructive-ops.test.ts +++ b/tests/sdk/destructive-ops.test.ts @@ -41,8 +41,11 @@ function makeState(isProtected: boolean): ContextState { return { connection: null, config: makeConfig(isProtected), - settings: {} as Settings, - identity: {} as Identity, + settings: {}, + identity: { + name: 'tester', + source: 'system', + }, options: {}, projectRoot: '/tmp', changeManager: null, diff --git a/tests/sdk/lifecycle.test.ts b/tests/sdk/lifecycle.test.ts index 15146201..0e58fcb7 100644 --- a/tests/sdk/lifecycle.test.ts +++ b/tests/sdk/lifecycle.test.ts @@ -52,7 +52,12 @@ describe('sdk: createContext factory', () => { const [, err] = await attempt(() => createContext({ config: '__nonexistent__' })); expect(err).toBeInstanceOf(Error); - expect((err as Error).message).toContain('not found'); + expect.assertions(2); + if (err instanceof Error) { + + expect(err.message).toContain('not found'); + + } }); From f1909ebd657ce882c947c9da0eed76598e632786 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Sun, 12 Apr 2026 23:15:53 -0400 Subject: [PATCH 11/15] fix(ci): pin bun to 1.3.11 in release binary workflow to avoid startup crash --- .changeset/fix-binary-bun-pin.md | 6 + .github/workflows/release-binary.yml | 2 + .../plans/2026-04-12-sdk-finish-line.md | 654 ++++++++++++++++++ 3 files changed, 662 insertions(+) create mode 100644 .changeset/fix-binary-bun-pin.md create mode 100644 docs/superpowers/plans/2026-04-12-sdk-finish-line.md diff --git a/.changeset/fix-binary-bun-pin.md b/.changeset/fix-binary-bun-pin.md new file mode 100644 index 00000000..86cda349 --- /dev/null +++ b/.changeset/fix-binary-bun-pin.md @@ -0,0 +1,6 @@ +--- +"@noormdev/cli": patch +--- + +## Fixed +* `fix(ci):` Pin bun to 1.3.11 in release binary workflow — bun 1.3.12 produces binaries that crash on startup (OOM kill, exit 137) diff --git a/.github/workflows/release-binary.yml b/.github/workflows/release-binary.yml index 80a64e02..0f9c01ea 100644 --- a/.github/workflows/release-binary.yml +++ b/.github/workflows/release-binary.yml @@ -16,6 +16,8 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.11 - name: Install dependencies run: bun install --frozen-lockfile diff --git a/docs/superpowers/plans/2026-04-12-sdk-finish-line.md b/docs/superpowers/plans/2026-04-12-sdk-finish-line.md new file mode 100644 index 00000000..f1b33a9f --- /dev/null +++ b/docs/superpowers/plans/2026-04-12-sdk-finish-line.md @@ -0,0 +1,654 @@ +# SDK Finish Line Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Harden the noorm SDK for public release by removing the `allowProtected` escape hatch, expanding the protected-config guard to all destructive operations, and adding lifecycle + guard + destructive-ops test coverage against a real PostgreSQL instance. + +**Architecture:** Three workstreams executed in order — (1) remove `allowProtected` and simplify the guard signature, (2) wire the guard to additional destructive ops (`dt.importFile`, `changes.revert`), (3) write tests that prove all of the above. Tests use the project's existing Docker PG container (same infra as transfer/integration tests). No mocks for lifecycle tests. + +**Tech Stack:** TypeScript, Bun test runner, `@noormdev/sdk` source at `src/sdk/`, test files at `tests/sdk/`, PostgreSQL via Docker for lifecycle/destructive-ops tests. + +--- + +## Context for the implementer + +Before starting, understand these three files: + +- `src/sdk/guards.ts` — two guard functions and their error classes. `checkProtectedConfig` currently takes `(config, operation, options)` and reads `options.allowProtected`. +- `src/sdk/types.ts` — `CreateContextOptions` interface. `allowProtected` lives here. +- `src/sdk/namespaces/db.ts` — calls `checkProtectedConfig(this.#state.config, 'truncate/teardown/reset', this.#state.options)` for destructive ops. + +`checkRequireTest` is already correctly called before the connection pool opens (pool opens in `ctx.connect()`, not in `createContext()`). We need a test that proves this, not a code change. + +--- + +## Task 1: Remove `allowProtected` — types and guards + +**Files:** +- Modify: `src/sdk/types.ts` +- Modify: `src/sdk/guards.ts` + +- [ ] **Step 1: Remove `allowProtected` from `CreateContextOptions`** + +In `src/sdk/types.ts`, delete the `allowProtected` field and its JSDoc comment and example: + +```typescript +// REMOVE this field entirely from CreateContextOptions: +// /** Allow destructive ops on protected configs. Default: false */ +// allowProtected?: boolean; + +// Also remove the allowProtected example from the @example block: +// // Allow destructive ops on protected config +// const ctx = await createContext({ +// config: 'staging', +// allowProtected: true, +// }) +``` + +After edit, `CreateContextOptions` should have only: `config`, `projectRoot`, `requireTest`, `stage`. + +- [ ] **Step 2: Simplify `checkProtectedConfig` signature** + +In `src/sdk/guards.ts`, change the function signature — drop the `options` parameter entirely: + +```typescript +/** + * Check if operation is allowed on protected config. + * + * Protected configs unconditionally block all destructive operations. + * There is no override. To run a destructive op against a protected + * config, the user must manually set config.protected = false first. + * + * @throws ProtectedConfigError if config is protected + */ +export function checkProtectedConfig( + config: Config, + operation: string, +): void { + + if (config.protected) { + + throw new ProtectedConfigError(config.name, operation); + + } + +} +``` + +- [ ] **Step 3: Remove the `allowProtected` JSDoc example from `ProtectedConfigError`** + +In `src/sdk/guards.ts`, update the `ProtectedConfigError` JSDoc to remove the reference to `allowProtected`: + +```typescript +/** + * Error thrown when attempting destructive operations on protected configs. + * + * @example + * ```typescript + * // config.protected is true — all destructive ops are blocked + * await ctx.noorm.db.truncate() // Throws ProtectedConfigError + * ``` + */ +``` + +- [ ] **Step 4: Verify TypeScript compiles** + +```bash +bun run typecheck +``` + +Expected: type errors at the three `db.ts` call sites (too many arguments). This is expected — fix in next task. + +--- + +## Task 2: Update `db.ts` call sites + +**Files:** +- Modify: `src/sdk/namespaces/db.ts` + +- [ ] **Step 1: Update the three `checkProtectedConfig` calls** + +In `src/sdk/namespaces/db.ts`, remove `this.#state.options` from all three calls: + +```typescript +// truncate (line ~277) +checkProtectedConfig(this.#state.config, 'truncate'); + +// teardown (line ~299) +checkProtectedConfig(this.#state.config, 'teardown'); + +// reset (line ~320) +checkProtectedConfig(this.#state.config, 'reset'); +``` + +- [ ] **Step 2: Verify TypeScript compiles cleanly** + +```bash +bun run typecheck +``` + +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/sdk/guards.ts src/sdk/types.ts src/sdk/namespaces/db.ts +git commit -m "feat(sdk): remove allowProtected — protected configs unconditionally block destructive ops" +``` + +--- + +## Task 3: Extend protected guard to `dt.importFile` + +**Files:** +- Modify: `src/sdk/namespaces/dt.ts` + +The `dt.importFile` operation bulk-writes data into the database. On a protected config this is a destructive operation. + +- [ ] **Step 1: Read the current `importFile` method** + +In `src/sdk/namespaces/dt.ts`, find `importFile` (the method that calls `importDtFile`). + +- [ ] **Step 2: Add `checkProtectedConfig` import and guard** + +At the top of `src/sdk/namespaces/dt.ts`, add the import: + +```typescript +import { checkProtectedConfig } from '../guards.js'; +``` + +Inside the `importFile` method, add the guard as the first line of the method body: + +```typescript +async importFile( + filepath: string, + options?: ImportOptions, +): Promise<[{ rowsImported: number } | null, Error | null]> { + + checkProtectedConfig(this.#state.config, 'dt.import'); + + return importDtFile({ ... }); // existing call unchanged + +} +``` + +- [ ] **Step 3: Verify TypeScript compiles** + +```bash +bun run typecheck +``` + +Expected: no errors. + +- [ ] **Step 4: Commit** + +```bash +git add src/sdk/namespaces/dt.ts +git commit -m "feat(sdk): block dt.importFile on protected configs" +``` + +--- + +## Task 4: Extend protected guard to `changes.revert` + +**Files:** +- Modify: `src/sdk/namespaces/changes.ts` + +Rolling back a change in production is destructive (it drops schema or data). Forward migrations are fine on protected configs; only revert/rollback ops are blocked. + +- [ ] **Step 1: Read `changes.ts` revert methods** + +```bash +grep -n "revert\|revertOne\|ff\|apply\b" src/sdk/namespaces/changes.ts +``` + +Note: `revert(name)` and any batch revert ops need the guard. `apply`, `ff`, `applyOne` do NOT get the guard (forward migrations are safe). + +- [ ] **Step 2: Add import and guard to `revert`** + +At the top of `src/sdk/namespaces/changes.ts`, add: + +```typescript +import { checkProtectedConfig } from '../guards.js'; +``` + +In the `revert` method, add the guard as the first line: + +```typescript +async revert(name: string, options?: ChangeOptions): Promise { + + checkProtectedConfig(this.#state.config, 'changes.revert'); + + return this.#getManager().revert(name, options); + +} +``` + +If there is a `revertOne` or batch revert method, apply the same pattern to each. + +- [ ] **Step 3: Verify TypeScript compiles** + +```bash +bun run typecheck +``` + +Expected: no errors. + +- [ ] **Step 4: Commit** + +```bash +git add src/sdk/namespaces/changes.ts +git commit -m "feat(sdk): block changes.revert on protected configs" +``` + +--- + +## Task 5: Write `tests/sdk/guards.test.ts` + +**Files:** +- Create: `tests/sdk/guards.test.ts` + +These tests are unit-level — no real DB needed. Use a mock config object. + +- [ ] **Step 1: Create the test file** + +```typescript +import { describe, it, expect } from 'bun:test'; +import { checkRequireTest, checkProtectedConfig, RequireTestError, ProtectedConfigError } from '../../src/sdk/guards.js'; +import type { Config } from '../../src/core/config/types.js'; + +function makeConfig(overrides: Partial = {}): Config { + return { + name: 'test', + type: 'local', + isTest: true, + protected: false, + connection: { dialect: 'postgres', database: 'testdb' }, + ...overrides, + } as Config; +} + +describe('checkRequireTest', () => { + it('does not throw when requireTest is false', () => { + expect(() => checkRequireTest(makeConfig({ isTest: false }), { requireTest: false })).not.toThrow(); + }); + + it('does not throw when requireTest is true and config.isTest is true', () => { + expect(() => checkRequireTest(makeConfig({ isTest: true }), { requireTest: true })).not.toThrow(); + }); + + it('throws RequireTestError when requireTest is true but config.isTest is false', () => { + const config = makeConfig({ name: 'prod', isTest: false }); + expect(() => checkRequireTest(config, { requireTest: true })).toThrow(RequireTestError); + }); + + it('RequireTestError carries the config name', () => { + const config = makeConfig({ name: 'prod', isTest: false }); + let caught: unknown; + try { checkRequireTest(config, { requireTest: true }); } catch (e) { caught = e; } + expect(caught).toBeInstanceOf(RequireTestError); + expect((caught as RequireTestError).configName).toBe('prod'); + }); + + it('does not throw when requireTest is omitted', () => { + expect(() => checkRequireTest(makeConfig({ isTest: false }), {})).not.toThrow(); + }); +}); + +describe('checkProtectedConfig', () => { + it('does not throw on non-protected config', () => { + expect(() => checkProtectedConfig(makeConfig({ protected: false }), 'truncate')).not.toThrow(); + }); + + it('throws ProtectedConfigError on protected config', () => { + const config = makeConfig({ name: 'prod', protected: true }); + expect(() => checkProtectedConfig(config, 'truncate')).toThrow(ProtectedConfigError); + }); + + it('ProtectedConfigError carries config name and operation', () => { + const config = makeConfig({ name: 'prod', protected: true }); + let caught: unknown; + try { checkProtectedConfig(config, 'teardown'); } catch (e) { caught = e; } + expect(caught).toBeInstanceOf(ProtectedConfigError); + expect((caught as ProtectedConfigError).configName).toBe('prod'); + expect((caught as ProtectedConfigError).operation).toBe('teardown'); + }); + + it('blocks all named destructive operations', () => { + const config = makeConfig({ name: 'prod', protected: true }); + const ops = ['truncate', 'teardown', 'reset', 'dt.import', 'changes.revert']; + for (const op of ops) { + expect(() => checkProtectedConfig(config, op)).toThrow(ProtectedConfigError); + } + }); +}); +``` + +- [ ] **Step 2: Run the tests** + +```bash +bun test tests/sdk/guards.test.ts +``` + +Expected: all pass. + +- [ ] **Step 3: Commit** + +```bash +git add tests/sdk/guards.test.ts +git commit -m "test(sdk): add guard unit tests for requireTest and protected config" +``` + +--- + +## Task 6: Write `tests/sdk/lifecycle.test.ts` + +**Files:** +- Create: `tests/sdk/lifecycle.test.ts` + +These tests verify `createContext` + `connect`/`disconnect` behavior. Most cases do NOT require a real DB — they test that the factory rejects early before any connection is attempted. + +For the "pool never opens on requireTest failure" test we need to verify that `ctx.connect()` was never called, which we can prove by checking that `checkRequireTest` throws inside `createContext` itself (before `ctx.connect()` is ever called). + +- [ ] **Step 1: Create the test file** + +```typescript +import { describe, it, expect } from 'bun:test'; +import { createContext, RequireTestError } from '../../src/sdk/index.js'; +import type { CreateContextOptions } from '../../src/sdk/index.js'; + +// ───────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────── + +/** + * Build a minimal CreateContextOptions that points at a fake project root + * with a test config. We use env-var override mode so no real state file + * is needed. + * + * NOTE: These tests must NOT call ctx.connect() — they test factory behavior, + * not DB connectivity. + */ +function envOnlyOptions(overrides: Partial = {}): CreateContextOptions { + return { + projectRoot: new URL('../../', import.meta.url).pathname, + ...overrides, + }; +} + +describe('createContext', () => { + it('throws when named config is not found', async () => { + await expect( + createContext({ ...envOnlyOptions(), config: '__nonexistent_config__' }) + ).rejects.toThrow('not found'); + }); + + it('throws RequireTestError when requireTest=true and config.isTest=false', async () => { + // We need a config where isTest=false. Use env-only mode to inject one. + process.env['NOORM_CONNECTION_DIALECT'] = 'postgres'; + process.env['NOORM_CONNECTION_DATABASE'] = 'testdb'; + process.env['NOORM_IS_TEST'] = 'false'; + + try { + await expect( + createContext({ ...envOnlyOptions(), requireTest: true }) + ).rejects.toThrow(RequireTestError); + } finally { + delete process.env['NOORM_CONNECTION_DIALECT']; + delete process.env['NOORM_CONNECTION_DATABASE']; + delete process.env['NOORM_IS_TEST']; + } + }); + + it('returns a Context that is not yet connected', async () => { + process.env['NOORM_CONNECTION_DIALECT'] = 'postgres'; + process.env['NOORM_CONNECTION_DATABASE'] = 'testdb'; + process.env['NOORM_IS_TEST'] = 'true'; + + try { + const ctx = await createContext(envOnlyOptions()); + expect(ctx.connected).toBe(false); + } finally { + delete process.env['NOORM_CONNECTION_DIALECT']; + delete process.env['NOORM_CONNECTION_DATABASE']; + delete process.env['NOORM_IS_TEST']; + } + }); +}); + +describe('Context lifecycle', () => { + it('ctx.kysely throws if called before connect()', async () => { + process.env['NOORM_CONNECTION_DIALECT'] = 'postgres'; + process.env['NOORM_CONNECTION_DATABASE'] = 'testdb'; + process.env['NOORM_IS_TEST'] = 'true'; + + try { + const ctx = await createContext(envOnlyOptions()); + expect(() => ctx.kysely).toThrow('Not connected'); + } finally { + delete process.env['NOORM_CONNECTION_DIALECT']; + delete process.env['NOORM_CONNECTION_DATABASE']; + delete process.env['NOORM_IS_TEST']; + } + }); +}); +``` + +**Note:** Before writing these tests, check how env-only mode works by reading `src/core/config/resolver.ts` to confirm which env vars control `isTest`. If `NOORM_IS_TEST` is not a real env var, adjust the test to use whatever mechanism resolves `isTest` from env. If there is no env override for `isTest`, skip that specific test and leave a `// TODO` comment explaining why, rather than writing a test that can't pass. + +- [ ] **Step 2: Investigate env-only config resolution** + +```bash +grep -n "NOORM_IS_TEST\|isTest\|IS_TEST" src/core/config/resolver.ts src/core/config/env.ts 2>/dev/null | head -30 +``` + +Adjust the test file accordingly based on actual env var names. + +- [ ] **Step 3: Run tests** + +```bash +bun test tests/sdk/lifecycle.test.ts +``` + +Expected: all pass (or skip the isTest env test if that mechanism doesn't exist, and leave a clear comment). + +- [ ] **Step 4: Commit** + +```bash +git add tests/sdk/lifecycle.test.ts +git commit -m "test(sdk): add lifecycle tests for createContext and connection guards" +``` + +--- + +## Task 7: Write `tests/sdk/destructive-ops.test.ts` + +**Files:** +- Create: `tests/sdk/destructive-ops.test.ts` + +These tests verify that the protected guard blocks all destructive operations. They do NOT require a real DB — the guard throws before any DB call is made. Use a mock Context built from a protected config. + +- [ ] **Step 1: Read how existing db-namespace tests create a context** + +```bash +cat tests/sdk/db-namespace.test.ts | head -80 +``` + +Use the same mock/fixture pattern. + +- [ ] **Step 2: Create the test file** + +```typescript +import { describe, it, expect } from 'bun:test'; +import { ProtectedConfigError } from '../../src/sdk/guards.js'; + +// Import namespaces directly to test guards without needing a real DB. +// We construct minimal ContextState objects to drive the guard. +import { DbNamespace } from '../../src/sdk/namespaces/db.js'; +import { DtNamespace } from '../../src/sdk/namespaces/dt.js'; +import { ChangesNamespace } from '../../src/sdk/namespaces/changes.js'; +import type { ContextState } from '../../src/sdk/state.js'; +import type { Config } from '../../src/core/config/types.js'; + +function makeProtectedState(): ContextState { + return { + connection: null, + config: { + name: 'prod', + type: 'local', + isTest: false, + protected: true, + connection: { dialect: 'postgres', database: 'testdb' }, + } as Config, + settings: {} as never, + identity: {} as never, + options: {}, + projectRoot: '/tmp', + changeManager: null, + }; +} + +function makeUnprotectedState(): ContextState { + return { + ...makeProtectedState(), + config: { + ...makeProtectedState().config, + name: 'dev', + protected: false, + }, + }; +} + +describe('protected config guard — db namespace', () => { + it('truncate throws ProtectedConfigError on protected config', async () => { + const ns = new DbNamespace(makeProtectedState()); + await expect(ns.truncate()).rejects.toThrow(ProtectedConfigError); + }); + + it('teardown throws ProtectedConfigError on protected config', async () => { + const ns = new DbNamespace(makeProtectedState()); + await expect(ns.teardown()).rejects.toThrow(ProtectedConfigError); + }); + + it('reset throws ProtectedConfigError on protected config', async () => { + const ns = new DbNamespace(makeProtectedState()); + await expect(ns.reset()).rejects.toThrow(ProtectedConfigError); + }); + + it('truncate does NOT throw on unprotected config (proceeds to DB call)', async () => { + const ns = new DbNamespace(makeUnprotectedState()); + // Will throw "Not connected" — that's past the guard, which is what we're testing + await expect(ns.truncate()).rejects.not.toThrow(ProtectedConfigError); + }); +}); + +describe('protected config guard — dt namespace', () => { + it('importFile throws ProtectedConfigError on protected config', async () => { + const ns = new DtNamespace(makeProtectedState()); + await expect(ns.importFile('./fake.dtz')).rejects.toThrow(ProtectedConfigError); + }); + + it('exportTable does NOT throw ProtectedConfigError (read-only op)', async () => { + const ns = new DtNamespace(makeUnprotectedState()); + // Will fail past the guard — that's what we're testing + await expect(ns.exportTable('users', './fake.dtz')).rejects.not.toThrow(ProtectedConfigError); + }); +}); + +describe('protected config guard — changes namespace', () => { + it('revert throws ProtectedConfigError on protected config', async () => { + const ns = new ChangesNamespace(makeProtectedState()); + await expect(ns.revert('2024-01-15-add-users')).rejects.toThrow(ProtectedConfigError); + }); + + it('apply does NOT throw ProtectedConfigError (forward migration)', async () => { + const ns = new ChangesNamespace(makeUnprotectedState()); + // Will fail past the guard — that's what we're testing + await expect(ns.apply('2024-01-15-add-users')).rejects.not.toThrow(ProtectedConfigError); + }); +}); +``` + +**Note:** The exact shape of `ContextState` may differ from what's shown above — check `src/sdk/state.ts` for the real interface and adjust field names accordingly. The DtNamespace and ChangesNamespace constructor signatures may also differ — read the actual constructors before writing. + +- [ ] **Step 3: Run tests** + +```bash +bun test tests/sdk/destructive-ops.test.ts +``` + +Expected: all pass. + +- [ ] **Step 4: Commit** + +```bash +git add tests/sdk/destructive-ops.test.ts +git commit -m "test(sdk): add destructive-ops tests proving protected guard blocks all destructive operations" +``` + +--- + +## Task 8: Run full test suite and verify no regressions + +- [ ] **Step 1: Run all SDK tests** + +```bash +bun test tests/sdk/ +``` + +Expected: all pass. If any test uses `allowProtected` and now fails, update that test to remove the option. + +- [ ] **Step 2: Grep for any remaining `allowProtected` references** + +```bash +grep -rn "allowProtected" src/ tests/ +``` + +Expected: zero matches. + +- [ ] **Step 3: Run typecheck** + +```bash +bun run typecheck +``` + +Expected: no errors. + +- [ ] **Step 4: Commit if any fixups were needed** + +```bash +git add -p +git commit -m "fix(sdk): remove remaining allowProtected references from tests" +``` + +--- + +## Task 9: Update `TODO.md` + +**Files:** +- Modify: `TODO.md` + +- [ ] **Step 1: Mark the three SDK Finish Line items as done** + +In `TODO.md`, under `### SDK Finish Line`, change: + +```markdown +- [ ] **SDK test coverage** - Dedicated tests for SDK surface (`createContext`, lifecycle, operations) +- [ ] **Test mode enforcement** - When `requireTest: true`, SDK must refuse to connect if `config.isTest !== true` +- [ ] **Protected config hard block** - Destructive operations (destroy, truncate, teardown) on protected configs are denied with no override. Remove `allowProtected` option entirely. +``` + +to: + +```markdown +- [x] **SDK test coverage** - Dedicated tests for SDK surface (`createContext`, lifecycle, operations) +- [x] **Test mode enforcement** - When `requireTest: true`, SDK must refuse to connect if `config.isTest !== true` +- [x] **Protected config hard block** - Destructive operations (destroy, truncate, teardown) on protected configs are denied with no override. Remove `allowProtected` option entirely. +``` + +- [ ] **Step 2: Commit** + +```bash +git add TODO.md +git commit -m "chore: mark SDK finish line items as complete in TODO" +``` From d4b1358a6232c79338a7f00a425436821a1a10d9 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Sun, 12 Apr 2026 23:17:48 -0400 Subject: [PATCH 12/15] chore: mark SDK finish line items as complete in TODO --- TODO.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/TODO.md b/TODO.md index 392f07ff..dbbea133 100644 --- a/TODO.md +++ b/TODO.md @@ -10,9 +10,9 @@ Critical work before public release. Core SDK is implemented and packaged (`@noormdev/sdk`). Remaining: -- [ ] **SDK test coverage** - Dedicated tests for SDK surface (`createContext`, lifecycle, operations) -- [ ] **Test mode enforcement** - When `requireTest: true`, SDK must refuse to connect if `config.isTest !== true` -- [ ] **Protected config hard block** - Destructive operations (destroy, truncate, teardown) on protected configs are denied with no override. User must perform manually. Remove `allowProtected` option entirely. +- [x] **SDK test coverage** - Dedicated tests for SDK surface (`createContext`, lifecycle, operations) +- [x] **Test mode enforcement** - When `requireTest: true`, SDK must refuse to connect if `config.isTest !== true` +- [x] **Protected config hard block** - Destructive operations (destroy, truncate, teardown) on protected configs are denied with no override. User must perform manually. Remove `allowProtected` option entirely. ### Headless CLI Gaps From 4566841f12b2df2763d4596c429b1d90e8b0e6fe Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Sun, 12 Apr 2026 23:19:22 -0400 Subject: [PATCH 13/15] chore: add changeset for SDK protected config hard block --- .changeset/sdk-protected-config-hardblock.md | 37 ++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .changeset/sdk-protected-config-hardblock.md diff --git a/.changeset/sdk-protected-config-hardblock.md b/.changeset/sdk-protected-config-hardblock.md new file mode 100644 index 00000000..e87f632f --- /dev/null +++ b/.changeset/sdk-protected-config-hardblock.md @@ -0,0 +1,37 @@ +--- +"@noormdev/sdk": major +--- + +## Breaking Changes + +### `allowProtected` option removed + +The `allowProtected` option has been removed from `CreateContextOptions`. Passing it no longer has any effect — protected configs unconditionally block all destructive operations with no override. + +**Before:** +```typescript +// This no longer works — allowProtected is not a valid option +const ctx = await createContext({ config: 'staging', allowProtected: true }) +await ctx.noorm.db.truncate() // would proceed +``` + +**After:** +```typescript +// Protected configs always block destructive ops — no override possible +const ctx = await createContext({ config: 'staging' }) +await ctx.noorm.db.truncate() // throws ProtectedConfigError +``` + +To run a destructive operation against a protected config, set `config.protected = false` manually before running the operation, then restore it. + +### `checkProtectedConfig` signature changed + +The exported `checkProtectedConfig` guard function signature changed from `(config, operation, options)` to `(config, operation)`. If you call this function directly, remove the third argument. + +## New Behavior + +The following operations are now blocked on protected configs (in addition to `truncate`, `teardown`, and `reset`): + +- `ctx.noorm.dt.importFile()` — bulk data import is destructive +- `ctx.noorm.changes.revert()` — schema rollbacks are destructive in production +- `ctx.noorm.changes.rewind()` — batch schema rollbacks are destructive in production From 706c00d65fdf67df4e43abbdef1d9712f75d7d23 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Sun, 12 Apr 2026 23:57:35 -0400 Subject: [PATCH 14/15] fix(tests): remove unused type imports in destructive-ops.test.ts --- tests/sdk/destructive-ops.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/sdk/destructive-ops.test.ts b/tests/sdk/destructive-ops.test.ts index cabb6400..7fd58398 100644 --- a/tests/sdk/destructive-ops.test.ts +++ b/tests/sdk/destructive-ops.test.ts @@ -17,8 +17,6 @@ import { ProtectedConfigError } from '../../src/sdk/guards.js'; import type { ContextState } from '../../src/sdk/state.js'; import type { Config } from '../../src/core/config/types.js'; -import type { Settings } from '../../src/core/settings/types.js'; -import type { Identity } from '../../src/core/identity/types.js'; // ───────────────────────────────────────────────────────────── // Fixtures From 1b9169d0c96569f66ff050aec2f84389799cff8a Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Mon, 13 Apr 2026 00:00:45 -0400 Subject: [PATCH 15/15] fix(tests): use React act() to flush effects in focus tests for React 19 compatibility --- tests/cli/focus.test.tsx | 44 ++++++++++++++++++++++++++-------------- 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/tests/cli/focus.test.tsx b/tests/cli/focus.test.tsx index 89946f32..946d22c7 100644 --- a/tests/cli/focus.test.tsx +++ b/tests/cli/focus.test.tsx @@ -5,9 +5,23 @@ */ import { describe, it, expect, vi } from 'bun:test'; import { render } from 'ink-testing-library'; -import React, { useEffect, useState } from 'react'; +import React, { act, useEffect, useState } from 'react'; import { Text } from 'ink'; +// React 19 requires this flag for act() to flush effects in non-browser environments +(globalThis as Record).IS_REACT_ACT_ENVIRONMENT = true; + +/** Flush all pending React effects and state updates */ +async function flushEffects(ms = 0) { + + await act(async () => { + + await new Promise((r) => setTimeout(r, ms)); + + }); + +} + import { FocusProvider, useFocusContext, @@ -135,7 +149,7 @@ describe('cli: focus', () => { , ); - await new Promise((resolve) => setTimeout(resolve, 10)); + await flushEffects(10); expect(lastFrame()).toContain('activeId:test-1'); expect(lastFrame()).toContain('stackLen:1'); @@ -176,7 +190,7 @@ describe('cli: focus', () => { , ); - await new Promise((resolve) => setTimeout(resolve, 10)); + await flushEffects(10); expect(lastFrame()).toContain('activeId:second'); expect(lastFrame()).toContain('stackLen:2'); @@ -218,7 +232,7 @@ describe('cli: focus', () => { , ); - await new Promise((resolve) => setTimeout(resolve, 10)); + await flushEffects(10); expect(lastFrame()).toContain('stackLen:1'); @@ -254,7 +268,7 @@ describe('cli: focus', () => { , ); - await new Promise((resolve) => setTimeout(resolve, 10)); + await flushEffects(10); expect(lastFrame()).toContain('labels:My Label'); @@ -272,7 +286,7 @@ describe('cli: focus', () => { , ); - await new Promise((resolve) => setTimeout(resolve, 10)); + await flushEffects(10); expect(lastFrame()).toContain('activeId:null'); expect(lastFrame()).toContain('stackLen:0'); @@ -313,7 +327,7 @@ describe('cli: focus', () => { , ); - await new Promise((resolve) => setTimeout(resolve, 10)); + await flushEffects(10); expect(lastFrame()).toContain('stackLen:1'); expect(lastFrame()).toContain('activeId:existing'); @@ -366,7 +380,7 @@ describe('cli: focus', () => { , ); - await new Promise((resolve) => setTimeout(resolve, 100)); + await flushEffects(100); expect(lastFrame()).toContain('stackLen:2'); expect(lastFrame()).toContain('stackIds:first,third'); @@ -402,7 +416,7 @@ describe('cli: focus', () => { , ); - await new Promise((resolve) => setTimeout(resolve, 10)); + await flushEffects(10); expect(lastFrame()).toContain('isActive:true'); @@ -433,7 +447,7 @@ describe('cli: focus', () => { , ); - await new Promise((resolve) => setTimeout(resolve, 10)); + await flushEffects(10); expect(lastFrame()).toContain('isActive:false'); @@ -507,7 +521,7 @@ describe('cli: focus', () => { ); // Initially mounted - wait for focus stack to initialize - await new Promise((resolve) => setTimeout(resolve, 10)); + await flushEffects(10); expect(lastFrame()).toContain('focused:true'); expect(lastFrame()).toContain('stackLen:1'); @@ -540,7 +554,7 @@ describe('cli: focus', () => { , ); - await new Promise((resolve) => setTimeout(resolve, 10)); + await flushEffects(10); // Rerender should keep same ID rerender( @@ -549,7 +563,7 @@ describe('cli: focus', () => { , ); - await new Promise((resolve) => setTimeout(resolve, 10)); + await flushEffects(10); // All collected IDs should be the same expect(ids.length).toBeGreaterThan(0); @@ -584,7 +598,7 @@ describe('cli: focus', () => { , ); - await new Promise((resolve) => setTimeout(resolve, 10)); + await flushEffects(10); expect(lastFrame()).toContain('focused:true'); @@ -637,7 +651,7 @@ describe('cli: focus', () => { , ); - await new Promise((resolve) => setTimeout(resolve, 10)); + await flushEffects(10); expect(lastFrame()).toContain('active:my-active');