Skip to content

chore: usability improvements#317

Merged
calvinbrewer merged 1 commit intomainfrom
stack-forge-1
Mar 10, 2026
Merged

chore: usability improvements#317
calvinbrewer merged 1 commit intomainfrom
stack-forge-1

Conversation

@calvinbrewer
Copy link
Contributor

@calvinbrewer calvinbrewer commented Mar 10, 2026

Summary by CodeRabbit

  • New Features

    • Interactive initialization wizard for project setup
    • Configuration validation, status checking, and database connection testing commands
    • Extension upgrade command with dry-run support
    • --latest flag to download latest EQL SQL from GitHub instead of using bundled version
  • Bug Fixes

    • Fixed EQL payload escaping in Supabase wrapper
    • Fixed push command compatibility with CipherStash Proxy
  • Documentation

    • Added guidance on ORDER BY limitations for encrypted columns without operator family support
    • Updated documentation for new CLI commands and interactive workflows

@changeset-bot
Copy link

changeset-bot bot commented Mar 10, 2026

🦋 Changeset detected

Latest commit: 3414761

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
Name Type
@cipherstash/stack Minor
@cipherstash/stack-forge Minor
@cipherstash/basic-example Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai
Copy link

coderabbitai bot commented Mar 10, 2026

📝 Walkthrough

Walkthrough

This PR introduces new CLI commands (init, validate, upgrade, status, test-connection) to stack-forge for interactive setup and configuration management, refactors SQL installation to use bundled EQL by default with a --latest flag to fetch from GitHub, updates schema defaults from 'text' to 'string' cast types, and adds comprehensive documentation across multiple packages.

Changes

Cohort / File(s) Summary
CLI Commands & Infrastructure
packages/stack-forge/src/bin/stash-forge.ts, packages/stack-forge/src/commands/index.ts
Added new command handlers (init, validate, upgrade, status, test-connection) with flag propagation for --latest, --dry-run, --supabase, and --exclude-operator-family; expanded command dispatcher to route to respective command implementations.
Interactive Init Command
packages/stack-forge/src/commands/init.ts
Implements comprehensive interactive initialization wizard (538 lines) with package detection, database URL validation, schema builder, integration type selection (Drizzle/Supabase/PostgreSQL), encryption client setup, code generation, and follow-up guidance.
Validation & Status Commands
packages/stack-forge/src/commands/validate.ts, packages/stack-forge/src/commands/status.ts, packages/stack-forge/src/commands/test-connection.ts
Added validation rules for encrypted columns (freeTextSearch on non-strings, operator family requirements, STE\_VEC type checks); status reports EQL installation/permissions/active config; test-connection verifies PostgreSQL connectivity with server details.
Upgrade & Install Commands
packages/stack-forge/src/commands/upgrade.ts, packages/stack-forge/src/commands/install.ts, packages/stack-forge/src/commands/push.ts
Upgrade command checks/upgrades EQL with dry-run support; install refactored to support bundled SQL default with --latest option; push command validates config and transforms cast types to EQL format before database write.
SQL Installation & Management
packages/stack-forge/src/installer/index.ts, packages/stack-forge/src/index.ts
Refactored EQLInstaller to use bundled SQL by default with conditional GitHub download via latest flag; exported new public functions loadBundledEqlSql() and downloadEqlSql(); improved Supabase permission grants (granular SELECT/EXECUTE/USAGE instead of ALL); added path resolution utilities for bundled SQL assets.
Schema Type System
packages/stack/src/schema/index.ts, packages/stack/__tests__/schema-builders.test.ts
Changed default cast type from 'text' to 'string'; added new EQL-specific cast mapping enum and toEqlCastAs() helper for SDK-to-EQL type conversion; updated test expectations to reflect 'string' default.
Supabase & PostgREST Integration
packages/stack/src/supabase/helpers.ts
Enhanced quote handling to support reserved characters (comma, parentheses, period) with POSTGREST_RESERVED regex; improved splitOrString() and parseOrValue() to handle quoted values; ensures proper escaping of complex filter expressions.
Configuration & Build
packages/stack-forge/src/config/index.ts, packages/stack-forge/package.json, packages/stack-forge/tsup.config.ts
Removed workspaceId and clientAccessKey optional fields from StashConfig; added dist/sql to published artifacts; configured build to copy src/sql assets to dist/sql on successful compilation.
Documentation Updates
packages/stack-forge/README.md, skills/stash-forge/SKILL.md, packages/stack/README.md, skills/stash-encryption/SKILL.md, skills/stash-drizzle/SKILL.md, skills/stash-supabase/SKILL.md
Expanded documentation for new commands (init, validate, upgrade, status), --latest flag usage, bundled vs. GitHub SQL options; added warnings about operator family limitations with Supabase; clarified ORDER BY encryption constraints and application-side sorting workarounds.
Changesets & Examples
.changeset/sharp-phones-own.md, .changeset/wet-zoos-follow.md, examples/basic/src/encryption/index.ts, examples/basic/stash.config.ts
Added version bump changesets for EQL escaping fix and new CLI tools; updated example to export helloTable schema and encryptionClient instance; updated config path reference.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • coderdan
  • auxesis

Poem

🐰 A rabbit hops through bundled SQL dreams,
Init commands and validators, or so it seems!
From 'text' to 'string' the schemas now dance,
While Supabase quotes get their well-earned chance. ✨🎯

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Title check ⚠️ Warning The title 'chore: usability improvements' is too vague and generic. It does not convey what specific usability improvements were made. Use a more specific title that identifies the primary changes, such as 'feat: add interactive init and validate/upgrade/status commands for stack-forge' or 'chore: expand stack-forge CLI with new commands and bundled SQL support'.
Docstring Coverage ⚠️ Warning Docstring coverage is 42.86% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch stack-forge-1

Tip

Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs).
Share your feedback on Discord.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (9)
packages/stack/README.md (1)

294-294: Clarify the client-side sort workaround for paginated queries.

Sorting after decryption is only safe if the app has the full candidate set. With LIMIT/pagination applied in SQL first, client-side sorting can return the wrong overall order. Please add that caveat so readers do not treat this as a drop-in replacement for ORDER BY.

Suggested wording
-On databases without operator families (e.g. Supabase, or when EQL is installed with `--exclude-operator-family`), sorting on encrypted columns is not currently supported — regardless of the client or ORM used. Sort application-side after decrypting the results as a workaround.
+On databases without operator families (e.g. Supabase, or when EQL is installed with `--exclude-operator-family`), sorting on encrypted columns is not currently supported — regardless of the client or ORM used. As a workaround, decrypt and sort application-side only when you have the full candidate result set; if SQL `LIMIT`/pagination is applied first, the final order may be incorrect.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/stack/README.md` at line 294, Update the sentence about client-side
sorting for encrypted columns to warn that sorting after decrypting is only
correct when the client has the full candidate set; explicitly state that if the
database query uses LIMIT, OFFSET or any server-side pagination (or applies
filtering that reduces the candidate set before returning results), performing
ORDER BY on the client after decryption can produce an incorrect global order,
and recommend either removing server-side pagination before fetching all
candidates or using a server-side sort supported by the DB/operator family as
alternatives; modify the existing paragraph that begins "On databases without
operator families..." to include this caveat and suggested remediation.
packages/stack-forge/src/commands/upgrade.ts (2)

64-70: Same consideration for post-upgrade version check.

The getInstalledVersion() call at line 65 also lacks error handling. If verification fails, it might be better to warn the user rather than crash.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/stack-forge/src/commands/upgrade.ts` around lines 64 - 70, The call
to installer.getInstalledVersion() in upgrade flow should be made safe: wrap the
getInstalledVersion() invocation (and the surrounding s.start/ s.stop
instrumentation) in a try/catch so failures don't crash the process; on catch,
call p.log.warn with a clear message referencing newVersion verification
(include the caught error), set newVersion to undefined (or leave as null) so
s.stop prints 'unknown', and continue to the existing comparison using
previousVersion and newVersion (preserving the p.log.info('Version unchanged —
EQL was already up to date.') path).

35-36: Missing error handling for getInstalledVersion.

If getInstalledVersion() throws (e.g., connection issue between checks), the error propagates unhandled. Consider wrapping in try-catch for a graceful failure message, similar to the pattern used in statusCommand.

♻️ Suggested improvement
-  const previousVersion = await installer.getInstalledVersion()
-  s.stop(`Current version: ${previousVersion ?? 'unknown'}`)
+  let previousVersion: string | null = null
+  try {
+    previousVersion = await installer.getInstalledVersion()
+    s.stop(`Current version: ${previousVersion ?? 'unknown'}`)
+  } catch (error) {
+    s.stop('Failed to get current version.')
+    p.log.warn('Could not determine current version, continuing with upgrade...')
+  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/stack-forge/src/commands/upgrade.ts` around lines 35 - 36, The call
to installer.getInstalledVersion() in upgrade command lacks error handling so
exceptions can bubble up; wrap the await installer.getInstalledVersion() in a
try-catch, assign previousVersion inside the try, and on catch call s.stop with
a helpful failure message (including the caught error) and return/exit similar
to the pattern used in statusCommand; reference the
installer.getInstalledVersion call and the previousVersion variable so you
update that block only.
packages/stack-forge/src/commands/push.ts (1)

13-28: Type assertion assumes valid input.

The as CastAs assertion on line 21 assumes column.cast_as is always a valid CastAs type. If EncryptConfig comes from external/user input with an invalid cast_as, this could pass unexpected values to toEqlCastAs.

If EncryptConfig is already validated by the schema loader, this is fine. Otherwise, consider defensive validation.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/stack-forge/src/commands/push.ts` around lines 13 - 28, The
toEqlConfig function currently force-casts column.cast_as to CastAs before
passing it to toEqlCastAs, which can propagate invalid user input; update
toEqlConfig to defensively validate column.cast_as (from EncryptConfig) using a
type guard or whitelist (e.g., check allowed CastAs values or switch) and either
convert/normalize it or throw a clear error/replace with a safe default before
calling toEqlCastAs; reference toEqlConfig, toEqlCastAs, EncryptConfig, CastAs
and the column.cast_as usage so you can locate and replace the direct `as
CastAs` assertion with the validation logic.
packages/stack-forge/src/commands/status.ts (1)

99-107: Fragile error detection via substring matching.

Checking message.includes('does not exist') is brittle — different PostgreSQL versions or locales could phrase this differently. Consider catching the specific PostgreSQL error code instead.

♻️ Suggested improvement using PostgreSQL error codes
   } catch (error) {
     s.stop('Configuration check failed.')

-    // The table may not exist if push has never been run — that's fine
-    const message = error instanceof Error ? error.message : String(error)
-    if (message.includes('does not exist')) {
+    // The table may not exist if push has never been run — that's fine
+    // PostgreSQL error code 42P01 = undefined_table
+    const pgError = error as { code?: string; message?: string }
+    if (pgError.code === '42P01') {
       p.log.info(
         'Active encrypt config: table not found (run `stash-forge push` to create it)',
       )
     } else {
+      const message = error instanceof Error ? error.message : String(error)
       p.log.error(`Failed to check encrypt configuration: ${message}`)
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/stack-forge/src/commands/status.ts` around lines 99 - 107, Replace
the fragile substring check of the error message with a check of the Postgres
error code (undefined_table = '42P01') instead: when handling the caught error
in the status command, cast the error to any or to the Postgres error type and
test error.code === '42P01' (or check typeof error.code === 'string' before
comparing) to detect "table not found" and call p.log.info(...) in that case;
otherwise fall back to p.log.error(...) for other errors. Use the existing
variables (error, message, p.log) and update the conditional around the
message/includes('does not exist') to the code-based check.
packages/stack-forge/src/installer/index.ts (1)

314-341: Code duplication between downloadInstallScript and downloadEqlSql.

The private downloadInstallScript method and the exported downloadEqlSql function have nearly identical implementations. Consider having the private method delegate to the exported function to reduce duplication.

♻️ Suggested refactor
   private async downloadInstallScript(
     excludeOperatorFamily: boolean,
   ): Promise<string> {
-    const url = excludeOperatorFamily
-      ? EQL_INSTALL_NO_OPERATOR_FAMILY_URL
-      : EQL_INSTALL_URL
-
-    let response: Response
-
-    try {
-      response = await fetch(url)
-    } catch (error) {
-      throw new Error('Failed to download EQL install script from GitHub.', {
-        cause: error,
-      })
-    }
-
-    if (!response.ok) {
-      throw new Error(
-        `Failed to download EQL install script from GitHub. HTTP ${response.status}: ${response.statusText}`,
-      )
-    }
-
-    return response.text()
+    return downloadEqlSql(excludeOperatorFamily)
   }

Also applies to: 385-413

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/stack-forge/src/installer/index.ts` around lines 314 - 341, Refactor
to remove duplication by having the class private method
downloadInstallScript(excludeOperatorFamily) delegate to the existing exported
function downloadEqlSql(excludeOperatorFamily): replace the duplicate
fetch/error handling inside downloadInstallScript with a call to downloadEqlSql
and return its result, preserving the excludeOperatorFamily boolean and letting
downloadEqlSql throw the same errors; ensure any differences in error wrapping
or Response handling are reconciled by updating downloadEqlSql (if needed) so
callers (including downloadInstallScript) receive consistent behavior.
skills/stash-forge/SKILL.md (1)

157-164: Minor formatting: Add blank line before table.

The markdown linter flags that tables should be surrounded by blank lines for better compatibility with various Markdown parsers.

📝 Proposed fix
 **Flags:**
+
 | Flag | Description |
 |------|-------------|

Apply similar fixes at lines 183 and 189.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@skills/stash-forge/SKILL.md` around lines 157 - 164, Add a blank line before
the Markdown table that follows the "Flags:" heading so the table is separated
from the preceding paragraph; similarly add blank lines before the other two
tables referenced in the file (the tables near the sections corresponding to the
occurrences noted at lines ~183 and ~189) to satisfy the markdown linter and
ensure consistent parsing.
packages/stack-forge/src/bin/stash-forge.ts (1)

80-83: Hardcoded version string.

The version '0.1.0' is hardcoded. Consider reading from package.json to keep the version in sync automatically.

💡 Suggested improvement
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'

// Near the top of the file or in a helper
function getVersion(): string {
  const __dirname = dirname(fileURLToPath(import.meta.url))
  const pkg = JSON.parse(readFileSync(join(__dirname, '../../package.json'), 'utf-8'))
  return pkg.version
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/stack-forge/src/bin/stash-forge.ts` around lines 80 - 83, Replace
the hardcoded version string in the CLI exit branch (the if checking
flags.version || command === '--version' || command === '-v') with a dynamic
read from package.json; add a helper like getVersion (or similar) that computes
__dirname via fileURLToPath(import.meta.url) and dirname, reads and parses the
package.json (using node:fs readFileSync or fs/promises), returns pkg.version,
and call that helper instead of printing '0.1.0' so the CLI output stays in sync
with package.json.
packages/stack-forge/src/commands/init.ts (1)

461-464: Top-level await in generated code.

The generated encryption client uses top-level await (export const encryptionClient = await Encryption({...})). This requires the consuming project to have "type": "module" in package.json or use .mjs extension. Consider documenting this requirement or generating an async factory function instead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/stack-forge/src/commands/init.ts` around lines 461 - 464, The
generated code uses top-level await for "export const encryptionClient = await
Encryption({...})", which forces consumers to use ESM; change this to an async
factory to avoid breaking projects: replace the top-level await with an exported
async function (e.g., "export async function createEncryptionClient() { return
Encryption({ schemas: [${schemaVarName}] }); }" or "export const
createEncryptionClient = async () => Encryption({...});"), update any internal
references to use createEncryptionClient(), and update generated docs/comments
to mention the async factory symbol instead of a direct constant.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/stack-forge/tsup.config.ts`:
- Around line 21-24: The post-build hook onSuccess currently uses
cpSync('src/sql', 'dist/sql', { recursive: true }) but doesn't remove stale
files; import and use rmSync to remove or empty the destination first—e.g., call
rmSync('dist/sql', { recursive: true, force: true }) (or rmSync followed by
mkdir if needed) before cpSync—so update the top-level imports to include rmSync
and modify the onSuccess block (the onSuccess function using cpSync) to clear
'dist/sql' prior to copying from 'src/sql'.

In `@packages/stack/src/supabase/helpers.ts`:
- Around line 208-225: formatOrValue and parseOrValue mishandle internal double
quotes: when a string matches POSTGREST_RESERVED and is wrapped in double
quotes, internal " characters must be escaped as \" and conversely parseOrValue
must unescape \" back to " when stripping outer quotes. Update formatOrValue to
replace all internal double quotes with escaped quotes before surrounding with
outer quotes (preserving existing POSTGREST_RESERVED check), and update
parseOrValue to detect a value wrapped in double quotes, remove the outer
quotes, and replace escaped sequences (\") with actual double quotes.

---

Nitpick comments:
In `@packages/stack-forge/src/bin/stash-forge.ts`:
- Around line 80-83: Replace the hardcoded version string in the CLI exit branch
(the if checking flags.version || command === '--version' || command === '-v')
with a dynamic read from package.json; add a helper like getVersion (or similar)
that computes __dirname via fileURLToPath(import.meta.url) and dirname, reads
and parses the package.json (using node:fs readFileSync or fs/promises), returns
pkg.version, and call that helper instead of printing '0.1.0' so the CLI output
stays in sync with package.json.

In `@packages/stack-forge/src/commands/init.ts`:
- Around line 461-464: The generated code uses top-level await for "export const
encryptionClient = await Encryption({...})", which forces consumers to use ESM;
change this to an async factory to avoid breaking projects: replace the
top-level await with an exported async function (e.g., "export async function
createEncryptionClient() { return Encryption({ schemas: [${schemaVarName}] });
}" or "export const createEncryptionClient = async () => Encryption({...});"),
update any internal references to use createEncryptionClient(), and update
generated docs/comments to mention the async factory symbol instead of a direct
constant.

In `@packages/stack-forge/src/commands/push.ts`:
- Around line 13-28: The toEqlConfig function currently force-casts
column.cast_as to CastAs before passing it to toEqlCastAs, which can propagate
invalid user input; update toEqlConfig to defensively validate column.cast_as
(from EncryptConfig) using a type guard or whitelist (e.g., check allowed CastAs
values or switch) and either convert/normalize it or throw a clear error/replace
with a safe default before calling toEqlCastAs; reference toEqlConfig,
toEqlCastAs, EncryptConfig, CastAs and the column.cast_as usage so you can
locate and replace the direct `as CastAs` assertion with the validation logic.

In `@packages/stack-forge/src/commands/status.ts`:
- Around line 99-107: Replace the fragile substring check of the error message
with a check of the Postgres error code (undefined_table = '42P01') instead:
when handling the caught error in the status command, cast the error to any or
to the Postgres error type and test error.code === '42P01' (or check typeof
error.code === 'string' before comparing) to detect "table not found" and call
p.log.info(...) in that case; otherwise fall back to p.log.error(...) for other
errors. Use the existing variables (error, message, p.log) and update the
conditional around the message/includes('does not exist') to the code-based
check.

In `@packages/stack-forge/src/commands/upgrade.ts`:
- Around line 64-70: The call to installer.getInstalledVersion() in upgrade flow
should be made safe: wrap the getInstalledVersion() invocation (and the
surrounding s.start/ s.stop instrumentation) in a try/catch so failures don't
crash the process; on catch, call p.log.warn with a clear message referencing
newVersion verification (include the caught error), set newVersion to undefined
(or leave as null) so s.stop prints 'unknown', and continue to the existing
comparison using previousVersion and newVersion (preserving the
p.log.info('Version unchanged — EQL was already up to date.') path).
- Around line 35-36: The call to installer.getInstalledVersion() in upgrade
command lacks error handling so exceptions can bubble up; wrap the await
installer.getInstalledVersion() in a try-catch, assign previousVersion inside
the try, and on catch call s.stop with a helpful failure message (including the
caught error) and return/exit similar to the pattern used in statusCommand;
reference the installer.getInstalledVersion call and the previousVersion
variable so you update that block only.

In `@packages/stack-forge/src/installer/index.ts`:
- Around line 314-341: Refactor to remove duplication by having the class
private method downloadInstallScript(excludeOperatorFamily) delegate to the
existing exported function downloadEqlSql(excludeOperatorFamily): replace the
duplicate fetch/error handling inside downloadInstallScript with a call to
downloadEqlSql and return its result, preserving the excludeOperatorFamily
boolean and letting downloadEqlSql throw the same errors; ensure any differences
in error wrapping or Response handling are reconciled by updating downloadEqlSql
(if needed) so callers (including downloadInstallScript) receive consistent
behavior.

In `@packages/stack/README.md`:
- Line 294: Update the sentence about client-side sorting for encrypted columns
to warn that sorting after decrypting is only correct when the client has the
full candidate set; explicitly state that if the database query uses LIMIT,
OFFSET or any server-side pagination (or applies filtering that reduces the
candidate set before returning results), performing ORDER BY on the client after
decryption can produce an incorrect global order, and recommend either removing
server-side pagination before fetching all candidates or using a server-side
sort supported by the DB/operator family as alternatives; modify the existing
paragraph that begins "On databases without operator families..." to include
this caveat and suggested remediation.

In `@skills/stash-forge/SKILL.md`:
- Around line 157-164: Add a blank line before the Markdown table that follows
the "Flags:" heading so the table is separated from the preceding paragraph;
similarly add blank lines before the other two tables referenced in the file
(the tables near the sections corresponding to the occurrences noted at lines
~183 and ~189) to satisfy the markdown linter and ensure consistent parsing.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ddfd23c9-ffda-4940-881b-6471a16bcd0d

📥 Commits

Reviewing files that changed from the base of the PR and between 391f57f and 3414761.

📒 Files selected for processing (31)
  • .changeset/sharp-phones-own.md
  • .changeset/wet-zoos-follow.md
  • examples/basic/src/encryption/index.ts
  • examples/basic/stash.config.ts
  • packages/stack-forge/README.md
  • packages/stack-forge/package.json
  • packages/stack-forge/src/__tests__/installer.test.ts
  • packages/stack-forge/src/bin/stash-forge.ts
  • packages/stack-forge/src/commands/index.ts
  • packages/stack-forge/src/commands/init.ts
  • packages/stack-forge/src/commands/install.ts
  • packages/stack-forge/src/commands/push.ts
  • packages/stack-forge/src/commands/status.ts
  • packages/stack-forge/src/commands/test-connection.ts
  • packages/stack-forge/src/commands/upgrade.ts
  • packages/stack-forge/src/commands/validate.ts
  • packages/stack-forge/src/config/index.ts
  • packages/stack-forge/src/index.ts
  • packages/stack-forge/src/installer/index.ts
  • packages/stack-forge/src/sql/cipherstash-encrypt-no-operator-family.sql
  • packages/stack-forge/src/sql/cipherstash-encrypt-supabase.sql
  • packages/stack-forge/src/sql/cipherstash-encrypt.sql
  • packages/stack-forge/tsup.config.ts
  • packages/stack/README.md
  • packages/stack/__tests__/schema-builders.test.ts
  • packages/stack/src/schema/index.ts
  • packages/stack/src/supabase/helpers.ts
  • skills/stash-drizzle/SKILL.md
  • skills/stash-encryption/SKILL.md
  • skills/stash-forge/SKILL.md
  • skills/stash-supabase/SKILL.md
💤 Files with no reviewable changes (1)
  • packages/stack-forge/src/config/index.ts

Comment on lines +21 to +24
onSuccess: async () => {
// Copy bundled SQL files into dist so they ship with the package
cpSync('src/sql', 'dist/sql', { recursive: true })
},
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cd packages/stack-forge && head -40 tsup.config.ts

Repository: cipherstash/stack

Length of output: 1147


🌐 Web query:

Node.js fs.cpSync behavior overwrite stale files

💡 Result:

fs.cpSync() does not try to be “smart” about staleness (mtime/size/contents). It simply copies, and its overwrite behavior is controlled by options:

  • Default is overwrite: force: true by default, meaning existing destination paths are overwritten. If you set force: false and the destination exists, Node will ignore the conflict unless you also set errorOnExist: true to throw instead. [1]
  • It will not delete “stale” destination files (files that exist in dest but no longer exist in src). It only copies what’s in src. (So “stale files” remain unless you remove them yourself.) [1]

To ensure dest exactly matches src, do a wipe-then-copy:

import fs from "node:fs";

fs.rmSync(dest, { recursive: true, force: true });
fs.cpSync(src, dest, { recursive: true, force: true });

fs.rmSync(..., { recursive: true, force: true }) is the Node-provided equivalent of rm -rf. [1]

Sources:
[1] Node.js File System docs (fs.cpSync, force, errorOnExist, fs.rmSync) https://nodejs.org/api/fs.html
[2] Exploring JS summary of fs.cpSync options (force default true, errorOnExist) https://2ality.com/2022/06/nodejs-file-system.html


Clear dist/sql before copying into it.

fs.cpSync() only overwrites files it copies from the source; it leaves destination-only files untouched. Deleted or renamed files under src/sql will persist in dist/sql across rebuilds, potentially causing stale SQL assets to ship with the package. Update the import on line 1 to include rmSync and wipe the destination directory before copying.

♻️ Suggested change
-import { cpSync } from 'node:fs'
+import { cpSync, rmSync } from 'node:fs'
    onSuccess: async () => {
      // Copy bundled SQL files into dist so they ship with the package
+     rmSync('dist/sql', { recursive: true, force: true })
      cpSync('src/sql', 'dist/sql', { recursive: true })
    },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/stack-forge/tsup.config.ts` around lines 21 - 24, The post-build
hook onSuccess currently uses cpSync('src/sql', 'dist/sql', { recursive: true })
but doesn't remove stale files; import and use rmSync to remove or empty the
destination first—e.g., call rmSync('dist/sql', { recursive: true, force: true
}) (or rmSync followed by mkdir if needed) before cpSync—so update the top-level
imports to include rmSync and modify the onSuccess block (the onSuccess function
using cpSync) to clear 'dist/sql' prior to copying from 'src/sql'.

Comment on lines 208 to +225
function formatOrValue(value: unknown): string {
if (Array.isArray(value)) {
return `(${value.join(',')})`
}
if (value === null) return 'null'
if (value === true) return 'true'
if (value === false) return 'false'
return String(value)

const str = String(value)

// Wrap in double quotes if the value contains reserved characters.
// This is required for encrypted values (JSON with commas, braces, etc.)
// and is safe for all string values per PostgREST spec.
if (POSTGREST_RESERVED.test(str)) {
return `"${str}"`
}

return str
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

How does PostgREST handle escaped double quotes within quoted filter values?

💡 Result:

PostgREST lets you escape a double quote inside a double-quoted filter value with a backslash (\"). If you need a literal backslash, escape it as \\. This is mainly relevant when you’re using double-quoted values to protect reserved characters (often in in.(...)). [1][2]

Example (URL-encoded form shown in the docs):

  • A value containing ": Quote:" is sent as %22Quote:%5C%22%22 (because \ is %5C and " is %22). [1]
  • A value containing \: Backslash:\ is sent as %22Backslash:%5C%5C%22. [1]

Sources:
[1] PostgREST docs (Reserved characters / escaping in filters) (docs.postgrest.org)
[2] PostgREST release notes v9.0.0 (added support for escaping quotes in double-quoted strings) (postgrest.org)

Citations:


🏁 Script executed:

# Search for formatOrValue usage and POSTGREST_RESERVED definition
rg -n "formatOrValue|POSTGREST_RESERVED" packages/stack/src/supabase/helpers.ts -B 2 -A 2

Repository: cipherstash/stack

Length of output: 697


🏁 Script executed:

# Check for any existing escaping or sanitization of double quotes
rg -n "escape|sanitize|quote" packages/stack/src/supabase/helpers.ts

Repository: cipherstash/stack

Length of output: 215


🏁 Script executed:

# Look for test cases or examples showing expected behavior
fd "*.test\|*.spec" packages/stack/src/supabase/ --exec grep -l "formatOrValue\|POSTGREST" {} \;

Repository: cipherstash/stack

Length of output: 354


🏁 Script executed:

# Find test files with correct syntax
fd -e test.ts -e spec.ts -e test.js packages/stack/src/supabase/

Repository: cipherstash/stack

Length of output: 478


🏁 Script executed:

# Look at the broader context where formatOrValue is used
rg -B 10 "formatOrValue(c.value)" packages/stack/src/supabase/helpers.ts | head -30

Repository: cipherstash/stack

Length of output: 43


🏁 Script executed:

# Check parseOrValue to understand the inverse operation
sed -n '178,201p' packages/stack/src/supabase/helpers.ts

Repository: cipherstash/stack

Length of output: 634


🏁 Script executed:

# Check if there's any escaping logic elsewhere in the file
rg -n "\\\\\"" packages/stack/src/supabase/helpers.ts

Repository: cipherstash/stack

Length of output: 43


🏁 Script executed:

# Look at the complete formatOrValue function
sed -n '208,226p' packages/stack/src/supabase/helpers.ts

Repository: cipherstash/stack

Length of output: 602


🏁 Script executed:

# Search for any test files related to these helper functions
fd . 'packages/stack/src/supabase/' -e test.ts -e spec.ts

Repository: cipherstash/stack

Length of output: 43


🏁 Script executed:

# Check if there are usage examples or test patterns
rg "formatOrValue|parseOrValue" packages/stack/src/ --type ts -A 3 -B 1 | head -50

Repository: cipherstash/stack

Length of output: 1445


Fix double quote escaping in formatOrValue and parseOrValue to prevent PostgREST parsing errors.

The current implementation does not escape internal double quotes within values that contain reserved characters. PostgREST requires backslash escaping: a double quote inside a double-quoted value must be escaped as \".

For example, a value like foo,"bar" (containing both a comma and internal quotes) currently produces "foo,"bar" which will fail PostgREST parsing. It should produce "foo,\"bar\"".

Update formatOrValue to escape internal double quotes before wrapping:

if (POSTGREST_RESERVED.test(str)) {
  return `"${str.replace(/"/g, '\\"')}"`
}

Also update parseOrValue to unescape internal quotes when stripping the outer quotes:

if (value.startsWith('"') && value.endsWith('"')) {
  return value.slice(1, -1).replace(/\\"/g, '"')
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/stack/src/supabase/helpers.ts` around lines 208 - 225, formatOrValue
and parseOrValue mishandle internal double quotes: when a string matches
POSTGREST_RESERVED and is wrapped in double quotes, internal " characters must
be escaped as \" and conversely parseOrValue must unescape \" back to " when
stripping outer quotes. Update formatOrValue to replace all internal double
quotes with escaped quotes before surrounding with outer quotes (preserving
existing POSTGREST_RESERVED check), and update parseOrValue to detect a value
wrapped in double quotes, remove the outer quotes, and replace escaped sequences
(\") with actual double quotes.

@calvinbrewer calvinbrewer merged commit b6aeefb into main Mar 10, 2026
6 checks passed
@calvinbrewer calvinbrewer deleted the stack-forge-1 branch March 10, 2026 21:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant