@starci/hfs 4.11.0 → 4.11.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +43 -1
  3. package/db/drift.mjs +8 -6
  4. package/db/image.mjs +9 -4
  5. package/db/migrate.mjs +3 -3
  6. package/package.json +2 -1
  7. package/runtime/knowledge/hfs/canon-pins.yaml +13 -8
  8. package/runtime/knowledge/hfs/rules.yaml +30 -13
  9. package/runtime/knowledge/hfs/slots.yaml +63 -37
  10. package/runtime/knowledge/patterns/be/event-bus.yaml +12 -10
  11. package/runtime/knowledge/patterns/be/queues.yaml +2 -3
  12. package/runtime/knowledge/patterns/be/reactors.yaml +1 -1
  13. package/runtime/modules/kernel/failure-codes.yaml +17 -17
  14. package/runtime/modules/schemas/application-stacks.schema.yaml +4 -2
  15. package/runtime/scripts/api/sops/lib.mjs +30 -0
  16. package/runtime/scripts/api/sops/update-keys.mjs +20 -0
  17. package/runtime/scripts/gates/starcistacks.mjs +6 -2
  18. package/runtime/scripts/guards/push-scan.mjs +1 -1
  19. package/runtime/scripts/hfs/ci-declaration.mjs +3 -2
  20. package/runtime/scripts/hfs/db-baseline-verb.mjs +50 -10
  21. package/runtime/scripts/hfs/db-errors.mjs +2 -0
  22. package/runtime/scripts/hfs/db-plan-verb.mjs +17 -5
  23. package/runtime/scripts/hfs/db-squash.mjs +514 -0
  24. package/runtime/scripts/hfs/declaration-shape.mjs +3 -15
  25. package/runtime/scripts/hfs/example-secrets.mjs +4 -3
  26. package/runtime/scripts/hfs/rule-params-shape.mjs +11 -9
  27. package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -1
  28. package/runtime/scripts/hfs/rules/database-config.mjs +1 -1
  29. package/runtime/scripts/hfs/rules/database-constants.mjs +16 -1
  30. package/runtime/scripts/hfs/rules/database-queue-sources.mjs +2 -110
  31. package/runtime/scripts/hfs/rules/database-queues.mjs +53 -239
  32. package/runtime/scripts/hfs/rules/database-sql.mjs +2 -0
  33. package/runtime/scripts/hfs/rules/database-transaction.mjs +45 -0
  34. package/runtime/scripts/hfs/rules/deploy-scan.mjs +1 -1
  35. package/runtime/scripts/hfs/rules/deploy-workflow.mjs +19 -1
  36. package/runtime/scripts/hfs/rules/edition.mjs +1 -1
  37. package/runtime/scripts/hfs/rules/env-parity.mjs +1 -1
  38. package/runtime/scripts/hfs/rules/lite-secrets.mjs +3 -3
  39. package/runtime/scripts/hfs/rules/script-hygiene.mjs +1 -1
  40. package/runtime/scripts/hfs/rules/secret-custody-only.mjs +2 -2
  41. package/runtime/scripts/hfs/rules/secrets.mjs +1 -1
  42. package/runtime/scripts/hfs/rules/stack-tooling.mjs +1 -1
  43. package/runtime/scripts/hfs/rules/stacks-deploy.mjs +2 -1
  44. package/runtime/scripts/hfs/rules/stacks-lite-backing.mjs +2 -2
  45. package/runtime/scripts/hfs/rules/stacks-runtime.mjs +2 -2
  46. package/runtime/scripts/hfs/rules/stacks-swarm.mjs +1 -1
  47. package/runtime/scripts/hfs/rules/stacks.mjs +3 -3
  48. package/runtime/scripts/hfs/sealed-members.mjs +10 -6
  49. package/runtime/scripts/hfs/secret-files.mjs +16 -0
  50. package/runtime/scripts/hfs/secret-init.mjs +120 -21
  51. package/runtime/scripts/hfs/secret-output.mjs +2 -1
  52. package/runtime/scripts/hfs/secret-prompt.mjs +128 -0
  53. package/runtime/scripts/hfs/secret-sops.mjs +108 -9
  54. package/runtime/scripts/hfs/secret-standard.mjs +369 -0
  55. package/runtime/scripts/hfs/secret-store.mjs +23 -9
  56. package/runtime/scripts/hfs/secret.mjs +140 -46
  57. package/runtime/scripts/hfs/slots.mjs +8 -1
  58. package/runtime/scripts/hfs/stack-deploy-config.mjs +31 -10
  59. package/runtime/scripts/hfs/stacks-gate.mjs +9 -2
  60. package/runtime/scripts/hfs/starcistacks-runtime.mjs +1 -1
  61. package/runtime/scripts/lib/sealed-member.mjs +36 -0
  62. package/runtime/scripts/lib/secret-patterns.mjs +1 -1
  63. package/runtime/scripts/lib/swarm-stack.mjs +2 -2
  64. package/scaffold/add-inbox-lite.mjs +2 -2
  65. package/scaffold/app.mjs +2 -2
  66. package/src/main.mjs +6 -4
  67. package/stack/age-install.mjs +4 -3
  68. package/stack/build.mjs +1 -1
  69. package/stack/config.mjs +2 -1
  70. package/stack/context.mjs +32 -2
  71. package/stack/docker-transport.mjs +2 -1
  72. package/stack/remote.mjs +8 -5
  73. package/stack/sealed.mjs +9 -8
  74. package/stack/server-key.mjs +296 -0
  75. package/stack/ssh-session.mjs +7 -1
  76. package/stack/target-binding.mjs +2 -1
  77. package/sync/hygiene.mjs +1 -1
  78. package/sync/index.mjs +36 -10
  79. package/templates/app/ci-workflows/github/workflows/images.yml +2 -0
  80. package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +3 -1
  81. package/templates/app/ci-workflows/github/workflows/stack-key-restore.yml +58 -0
  82. package/templates/app/ci-workflows-lite/github/workflows/images.yml +2 -0
  83. package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +3 -1
  84. package/templates/app/ci-workflows-lite/github/workflows/stack-key-restore.yml +58 -0
  85. package/templates/app/db-bootstrap/db/bootstrap.sql +7 -4
  86. package/templates/app/db-image/starcistacks/images/db/Dockerfile +18 -0
  87. package/templates/app/gitignore +2 -1
  88. package/templates/app/gitignore-lite +3 -1
  89. package/templates/app/package-scripts/db.json +1 -1
  90. package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +1 -1
  91. package/templates/app/skeleton-lite/.starcistacks/dev/README.md +1 -1
  92. package/templates/app/skeleton-lite/.starcistacks/production/README.md +1 -1
  93. package/templates/app/skeleton-lite/README.md +2 -2
  94. package/templates/be/inbox/delivery-recorded.event.ts.tpl +7 -5
  95. package/templates/be/inbox/events.json.tpl +1 -1
  96. package/templates/be/inbox/platform/event-bus/event-bus.contracts.ts.tpl +58 -11
  97. package/templates/be/inbox/platform/event-bus/event-bus.decorators.ts.tpl +13 -1
  98. package/templates/be/inbox/platform/event-bus/event-bus.log-events.ts.tpl +9 -0
  99. package/templates/be/inbox/platform/event-bus/event-bus.module-definition.ts.tpl +1 -1
  100. package/templates/be/inbox/platform/event-bus/event-bus.module.ts.tpl +11 -5
  101. package/templates/be/inbox/platform/event-bus/event-bus.options.ts.tpl +9 -2
  102. package/templates/be/inbox/platform/event-bus/event-bus.port.ts.tpl +18 -5
  103. package/templates/be/inbox/platform/event-bus/event-bus.service.spec.ts.tpl +37 -16
  104. package/templates/be/inbox/platform/event-bus/event-bus.service.ts.tpl +33 -5
  105. package/templates/be/inbox/platform/event-bus/event-consumer.service.spec.ts.tpl +143 -0
  106. package/templates/be/inbox/platform/event-bus/event-consumer.service.ts.tpl +119 -0
  107. package/templates/be/inbox/platform/event-bus/index.ts.tpl +13 -4
  108. package/templates/be/inbox/platform/inbox/inbox.service.spec.ts.tpl +4 -4
  109. package/templates/be/inbox/platform/inbox/inbox.service.ts.tpl +4 -4
  110. package/templates/be/inbox/platform/migration.sql.tpl +97 -34
  111. package/templates/be/inbox/service.spec.ts.tpl +1 -1
  112. package/templates/be/inbox/webhook-door.ts.tpl +2 -2
  113. package/templates/be/patterns/jobs/platform-lite/job-claim.service.spec.ts.tpl +4 -4
  114. package/templates/be/patterns/jobs/platform-lite/job-claim.service.ts.tpl +5 -5
  115. package/templates/be/patterns/jobs/platform-lite/job-tick.service.spec.ts.tpl +1 -1
  116. package/templates/be/patterns/jobs/platform-lite/job-tick.service.ts.tpl +1 -1
  117. package/templates/be/patterns/queues/platform-lite/queue-outbox.service.spec.ts.tpl +1 -1
  118. package/templates/be/patterns/queues/platform-lite/queue-outbox.service.ts.tpl +1 -1
  119. package/templates/be/patterns/queues/platform-lite/queue-relay.service.spec.ts.tpl +2 -2
  120. package/templates/be/patterns/queues/platform-lite/queue-relay.service.ts.tpl +2 -2
  121. package/templates/be/skeleton/apps/__app__/src/__app__.env.ts +4 -0
  122. package/templates/be/skeleton/apps/__app__/src/__app__.options.ts +3 -0
  123. package/templates/be/skeleton/apps/__app__/src/compose/platform.ts +1 -1
  124. package/templates/be/skeleton/src/modules/platform/http/fetch-http-client.client.spec.ts +38 -3
  125. package/templates/be/skeleton/src/modules/platform/http/fetch-http-client.client.ts +36 -4
  126. package/templates/be/skeleton/src/modules/platform/http/http.config.ts +11 -0
  127. package/templates/be/skeleton/src/modules/platform/http/http.decorators.ts +7 -0
  128. package/templates/be/skeleton/src/modules/platform/http/http.module-definition.ts +5 -3
  129. package/templates/be/skeleton/src/modules/platform/http/http.options.ts +9 -2
  130. package/templates/be/skeleton/src/modules/platform/http/index.ts +2 -0
  131. package/templates/be/skeleton-lite/apps/api/src/api.env.ts +4 -0
  132. package/templates/be/skeleton-lite/apps/api/src/api.options.ts +3 -0
  133. package/templates/be/skeleton-lite/apps/api/src/compose/platform.ts +1 -1
  134. package/templates/be/skeleton-lite/src/modules/platform/database/database.client.spec.ts +96 -0
  135. package/templates/be/skeleton-lite/src/modules/platform/database/database.client.ts +93 -0
  136. package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +1 -1
  137. package/templates/be/skeleton-lite/src/tests/fixtures/builders/api.builder.ts +1 -0
  138. package/runtime/scripts/hfs/rules/database-queue-outbox.mjs +0 -102
  139. package/templates/app/db-image/db/Dockerfile +0 -8
  140. package/templates/be/skeleton-lite/src/modules/platform/database/database.functions.ts +0 -24
@@ -0,0 +1,96 @@
1
+ import { Test } from "@nestjs/testing"
2
+ import { mockEntityManager } from "@starci/jest-preset"
3
+ import { advisoryXactLock, callDbFunction, notify } from "./database.client"
4
+ import { DatabaseError, DatabaseErrorCode } from "./errors/database.error"
5
+
6
+ const DATABASE_CLIENT = Symbol("database-client")
7
+
8
+ const setup = async () => {
9
+ const moduleRef = await Test.createTestingModule({
10
+ providers: [{ provide: DATABASE_CLIENT, useFactory: () => ({ advisoryXactLock, callDbFunction, notify }) }],
11
+ }).compile()
12
+ return moduleRef.get<{
13
+ readonly advisoryXactLock: typeof advisoryXactLock
14
+ readonly callDbFunction: typeof callDbFunction
15
+ readonly notify: typeof notify
16
+ }>(DATABASE_CLIENT)
17
+ }
18
+
19
+ /** What `callDbFunction` throws for `name`, or null when it builds a call. */
20
+ const refusalOf = async (name: string): Promise<unknown> => {
21
+ const client = await setup()
22
+ try {
23
+ await client.callDbFunction(mockEntityManager({ query: ['SELECT * FROM "public"."never"()', []] }), name, [])
24
+ return null
25
+ } catch (error) {
26
+ return error
27
+ }
28
+ }
29
+
30
+ describe("callDbFunction", () => {
31
+ it("calls a schema-qualified function with each part quoted and the arguments as numbered parameters", async () => {
32
+ const client = await setup()
33
+ const manager = mockEntityManager({ query: ['SELECT * FROM "public"."job_claim"($1, $2)', [{ id: "job-1" }]] })
34
+
35
+ const rows = await client.callDbFunction<{ id: string }>(manager, "public.job_claim", ["mail", 3])
36
+
37
+ expect(rows).toEqual([{ id: "job-1" }])
38
+ expect(manager.query).toHaveBeenCalledWith('SELECT * FROM "public"."job_claim"($1, $2)', ["mail", 3])
39
+ })
40
+
41
+ it("calls a bare pg built-in of the canon's list in pg_catalog", async () => {
42
+ const client = await setup()
43
+ const manager = mockEntityManager({ query: ['SELECT * FROM "pg_catalog"."to_regproc"($1)', [{ to_regproc: null }]] })
44
+
45
+ const rows = await client.callDbFunction<{ to_regproc: string | null }>(manager, "to_regproc", ["public.job_claim"])
46
+
47
+ expect(rows).toEqual([{ to_regproc: null }])
48
+ })
49
+
50
+ it("calls a function of another schema with no arguments", async () => {
51
+ const client = await setup()
52
+ const manager = mockEntityManager({ query: ['SELECT * FROM "be_access"."chat_target_member"()', []] })
53
+
54
+ const rows = await client.callDbFunction(manager, "be_access.chat_target_member", [])
55
+
56
+ expect(rows).toEqual([])
57
+ })
58
+
59
+ it.each([
60
+ ["a bare name that is not a listed built-in", "job_claim"],
61
+ ["a pg_catalog name spelt out", "pg_catalog.pg_terminate_backend"],
62
+ ["a quote inside a part", 'public."job_claim'],
63
+ ["a second statement smuggled into the name", "public.job_claim(); SELECT 1; --"],
64
+ ["an upper-case part", "public.Job_Claim"],
65
+ ["three parts", "db.public.job_claim"],
66
+ ["an empty schema", ".job_claim"],
67
+ ["an empty name", ""],
68
+ ])("refuses %s before any SQL runs", async (_case, name) => {
69
+ const refusal = await refusalOf(name)
70
+
71
+ expect(refusal).toBeInstanceOf(DatabaseError)
72
+ expect(refusal).toMatchObject({ code: DatabaseErrorCode.IdentifierRejected, params: { name } })
73
+ })
74
+ })
75
+
76
+ describe("advisoryXactLock", () => {
77
+ it("locks the 64-bit hash of the key for the transaction", async () => {
78
+ const client = await setup()
79
+ const manager = mockEntityManager({ query: ["SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [{}]] })
80
+
81
+ await expect(client.advisoryXactLock(manager, "jobs:mail")).resolves.toBeUndefined()
82
+
83
+ expect(manager.query).toHaveBeenCalledWith("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", ["jobs:mail"])
84
+ })
85
+ })
86
+
87
+ describe("notify", () => {
88
+ it("sends the payload on the channel", async () => {
89
+ const client = await setup()
90
+ const manager = mockEntityManager({ query: ["SELECT pg_notify($1, $2)", [{}]] })
91
+
92
+ await expect(client.notify(manager, "jobs", '{"id":"job-1"}')).resolves.toBeUndefined()
93
+
94
+ expect(manager.query).toHaveBeenCalledWith("SELECT pg_notify($1, $2)", ["jobs", '{"id":"job-1"}'])
95
+ })
96
+ })
@@ -0,0 +1,93 @@
1
+ import type { EntityManager } from "typeorm"
2
+ import { DatabaseError, DatabaseErrorCode } from "./errors/database.error"
3
+
4
+ declare const functionPartBrand: unique symbol
5
+ declare const parameterListBrand: unique symbol
6
+
7
+ /** One part of a database function name that passed the identifier check: the only caller text a call statement holds. */
8
+ type DbFunctionPart = string & { readonly [functionPartBrand]: true }
9
+
10
+ /** The numbered parameters `$1, ..., $n` of one call, built from a count alone. */
11
+ type DbParameterList = string & { readonly [parameterListBrand]: true }
12
+
13
+ /** A function the call statement names: its schema and its name, each part checked. */
14
+ interface DbFunctionTarget {
15
+ readonly schema: DbFunctionPart
16
+ readonly name: DbFunctionPart
17
+ }
18
+
19
+ /** The pg built-ins the canon lists bare in ruleParams.be.dbFunctions; each is called in pg_catalog. */
20
+ const PG_BUILTINS: ReadonlySet<string> = new Set([
21
+ "pg_advisory_xact_lock",
22
+ "hashtextextended",
23
+ "pg_notify",
24
+ "to_regproc",
25
+ "gen_random_uuid",
26
+ ])
27
+
28
+ /** The schema that holds the pg built-ins. */
29
+ const BUILTIN_SCHEMA = "pg_catalog"
30
+
31
+ /** One unquoted lower-case identifier: a part of this shape needs no escaping inside double quotes. */
32
+ const PART_PATTERN = /^[a-z_][a-z0-9_]*$/
33
+
34
+ function assertFunctionPart(_value: string): asserts _value is DbFunctionPart {
35
+ // The brand is compile-time only; `functionPart` is the single caller after checking the part.
36
+ }
37
+
38
+ function assertParameterList(_value: string): asserts _value is DbParameterList {
39
+ // The brand is compile-time only; `parameterList` is the single caller.
40
+ }
41
+
42
+ /** The refusal of a name that is not a callable database function. */
43
+ const rejected = (name: string): DatabaseError =>
44
+ new DatabaseError({ code: DatabaseErrorCode.IdentifierRejected, params: { name } })
45
+
46
+ /** Checks one part of a function name against the identifier shape and brands it. */
47
+ const functionPart = (part: string, name: string): DbFunctionPart => {
48
+ if (!PART_PATTERN.test(part)) throw rejected(name)
49
+ assertFunctionPart(part)
50
+ return part
51
+ }
52
+
53
+ /**
54
+ * The function a name calls: `schema.name` for a function of a migration (never in pg_catalog), or a bare pg built-in of
55
+ * the canon's list, called in pg_catalog. Anything else is refused before any SQL is built.
56
+ */
57
+ const functionTarget = (name: string): DbFunctionTarget => {
58
+ const parts = name.split(".")
59
+ if (parts.length === 1 && PG_BUILTINS.has(name)) {
60
+ return { schema: functionPart(BUILTIN_SCHEMA, name), name: functionPart(name, name) }
61
+ }
62
+ const [schema, fn] = parts
63
+ if (parts.length !== 2 || schema === undefined || fn === undefined || schema === BUILTIN_SCHEMA) throw rejected(name)
64
+ return { schema: functionPart(schema, name), name: functionPart(fn, name) }
65
+ }
66
+
67
+ /** The numbered parameter list of a call with `count` arguments. */
68
+ const parameterList = (count: number): DbParameterList => {
69
+ const list = Array.from({ length: count }, (_, index) => `$${String(index + 1)}`).join(", ")
70
+ assertParameterList(list)
71
+ return list
72
+ }
73
+
74
+ /**
75
+ * Calls one database function TypeORM cannot express (a pg built-in or a SECURITY DEFINER function of a migration) with
76
+ * numbered parameters. The name is schema-qualified (`public.job_claim`) or a bare built-in of the canon's list, each part
77
+ * is checked and double-quoted, and the arguments are values, never text of the statement (R334, R68).
78
+ */
79
+ export const callDbFunction = <T>(manager: EntityManager, name: string, args: ReadonlyArray<unknown>): Promise<ReadonlyArray<T>> => {
80
+ const target = functionTarget(name)
81
+ const parameters = parameterList(args.length)
82
+ return manager.query<Array<T>>(`SELECT * FROM "${target.schema}"."${target.name}"(${parameters})`, [...args])
83
+ }
84
+
85
+ /** Takes a transaction-scoped advisory lock on the 64-bit hash of `key`; it is released when the manager's transaction ends. */
86
+ export const advisoryXactLock = async (manager: EntityManager, key: string): Promise<void> => {
87
+ await manager.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [key])
88
+ }
89
+
90
+ /** Sends one NOTIFY on `channel` with `payload`, delivered when the manager's transaction commits. */
91
+ export const notify = async (manager: EntityManager, channel: string, payload: string): Promise<void> => {
92
+ await manager.query("SELECT pg_notify($1, $2)", [channel, payload])
93
+ }
@@ -1,5 +1,5 @@
1
1
  export { DatabaseModule } from "./database.module"
2
- export { advisoryXactLock, callDbFunction, notify } from "./database.functions"
2
+ export { advisoryXactLock, callDbFunction, notify } from "./database.client"
3
3
  export { DATABASE_PROBE, InjectDatabaseProbe, InjectPrimaryEntityManager, PRIMARY_ENTITY_MANAGER } from "./primary.decorators"
4
4
  export { DatabaseProbeService } from "./database-probe.service"
5
5
  export type { DatabaseConnectionOptions } from "./database.options"
@@ -13,6 +13,7 @@ export const buildWiringProof = () => ({
13
13
  rateLimit: { windowMs: 60000, defaultLimit: 100, strictLimit: 10 },
14
14
  webhooks: {},
15
15
  },
16
+ http: {},
16
17
  database: {
17
18
  name: "primary",
18
19
  provider: "postgres",
@@ -1,102 +0,0 @@
1
- // R258: an app whose queue is BullMQ on a declared Redis may keep its own outbox beside the canon `ruleParams.db.queueOutbox` table. The
2
- // app declares it in hfs.json `sides.be.queueOutbox` ({table, enqueue, queueParameter}), and a pg_cron tick enqueues through the declared
3
- // `enqueue` function only while that function's active definition does nothing but write the declared table with its queue column bound
4
- // to the declared parameter. The checker reads the declaration and the function body; it names no app table or function.
5
- import pg from 'libpg-query';
6
- import { sqlStringParts as strings, walkSqlAst as walk, sqlParser } from '../../lib/sql-ast.mjs';
7
-
8
- await pg.loadModule();
9
-
10
- const sqlTree = sqlParser(pg);
11
- const STATEMENTS = new Set(['PLpgSQL_stmt_block', 'PLpgSQL_stmt_execsql', 'PLpgSQL_stmt_if', 'PLpgSQL_stmt_assign', 'PLpgSQL_stmt_return']);
12
- const OTHER_DML = ['UpdateStmt', 'DeleteStmt', 'MergeStmt'];
13
- const qualified = (name) => name.includes('.') ? name : `public.${name}`;
14
-
15
- /** The outbox an app declares, or null: it counts only while the back end declares the redis backing the relay hands rows to. */
16
- export function queueOutboxDeclaration(declaration) {
17
- const be = declaration?.sides?.be;
18
- const declared = be?.queueOutbox;
19
- if (!declared || !(be.backing ?? []).includes('redis')) return null;
20
- return { table: qualified(declared.table), enqueue: qualified(declared.enqueue), parameter: declared.queueParameter };
21
- }
22
-
23
- function plpgsqlStatements(fn, body) {
24
- const parameters = (fn.parameters ?? []).map((item) => item.FunctionParameter?.name).filter(Boolean).map((name) => `"${name.replaceAll('"', '""')}" text`).join(',');
25
- let tag = '$hfs_outbox$';
26
- while (body.includes(tag)) tag = `${tag.slice(0, -1)}_$`;
27
- let tree;
28
- try { tree = pg.parsePlPgSQLSync(`create function hfs_probe(${parameters}) returns text language plpgsql as ${tag}${body}${tag};`); } catch { return null; }
29
- const trees = [];
30
- let safe = true;
31
- walk(tree, (node) => {
32
- for (const key of Object.keys(node)) if (key.startsWith('PLpgSQL_stmt_') && !STATEMENTS.has(key)) safe = false;
33
- const expr = node.PLpgSQL_expr;
34
- if (expr) trees.push(sqlTree(expr.parseMode === 0 ? expr.query : `select ${expr.query}`));
35
- });
36
- return safe ? trees : null;
37
- }
38
-
39
- function bodyStatements(fn) {
40
- const option = (name) => (fn.options ?? []).find((item) => item.DefElem?.defname === name)?.DefElem?.arg;
41
- const language = option('language')?.String?.sval ?? 'sql';
42
- const body = strings(option('as')?.List?.items).join('');
43
- if (language === 'plpgsql') return plpgsqlStatements(fn, body);
44
- if (language !== 'sql') return null;
45
- if (fn.sql_body) return (fn.sql_body.List ? fn.sql_body.List.items : [fn.sql_body]).map((stmt) => ({ stmts: [{ stmt }] }));
46
- return sqlTree(body)?.stmts?.map((entry) => ({ stmts: [entry] })) ?? null;
47
- }
48
-
49
- /** True when the nodes hold no sub-select or other statement and call only functions `pureCall` admits. */
50
- function pureNodes(nodes, pureCall) {
51
- let ok = true;
52
- walk(nodes, (node) => {
53
- if (node.SubLink || node.SelectStmt || node.InsertStmt || OTHER_DML.some((key) => node[key])) ok = false;
54
- if (node.FuncCall && !pureCall(node.FuncCall)) ok = false;
55
- });
56
- return ok;
57
- }
58
-
59
- /** `insert into <table> (..., queue, ...) values (..., <parameter>, ...) [on conflict .. do nothing] [returning ..]` with pure other values. */
60
- function insertOk(declared, insert, pureCall) {
61
- if (insert.withClause || `${insert.relation?.schemaname || 'public'}.${insert.relation?.relname}` !== declared.table) return false;
62
- if (insert.onConflictClause && insert.onConflictClause.action !== 'ONCONFLICT_NOTHING') return false;
63
- const columns = (insert.cols ?? []).map((item) => item.ResTarget?.name);
64
- const rows = insert.selectStmt?.SelectStmt?.valuesLists ?? [];
65
- const values = rows.length === 1 ? rows[0].List?.items ?? [] : [];
66
- const at = columns.indexOf('queue');
67
- if (at < 0 || new Set(columns).size !== columns.length || values.length !== columns.length) return false;
68
- const bound = values[at].ColumnRef?.fields;
69
- if (!bound || strings(bound).join('.') !== declared.parameter || bound.length !== 1) return false;
70
- return pureNodes(values.filter((_, index) => index !== at), pureCall);
71
- }
72
-
73
- /** A read of the declared table with pure expressions, or an expression over variables alone. */
74
- function readOk(declared, tree, pureCall) {
75
- let ok = true;
76
- walk(tree, (node) => {
77
- if (OTHER_DML.some((key) => node[key]) || node.InsertStmt || node.SelectStmt?.intoClause) ok = false;
78
- if (node.FuncCall && !pureCall(node.FuncCall)) ok = false;
79
- if (node.RangeVar && `${node.RangeVar.schemaname || 'public'}.${node.RangeVar.relname}` !== declared.table) ok = false;
80
- });
81
- return ok;
82
- }
83
-
84
- function statementOk(declared, tree, pureCall) {
85
- const stmt = tree?.stmts?.length === 1 ? tree.stmts[0].stmt : null;
86
- if (stmt?.InsertStmt) return insertOk(declared, stmt.InsertStmt, pureCall);
87
- return Boolean(stmt?.SelectStmt) && readOk(declared, tree, pureCall);
88
- }
89
-
90
- /**
91
- * The position of the queue parameter when the active definition of the declared enqueue function only writes the declared outbox
92
- * (inserts, reads of that table and pure expressions), or -1. `pureCall` admits a function call that does no work.
93
- */
94
- export function outboxEnqueueQueueIndex(declared, entry, pureCall) {
95
- const fn = entry?.node;
96
- if (!fn) return -1;
97
- const index = (fn.parameters ?? []).findIndex((item) => item.FunctionParameter?.name === declared.parameter);
98
- const trees = bodyStatements(fn);
99
- if (index < 0 || !trees?.length || !trees.every((tree) => statementOk(declared, tree, pureCall))) return -1;
100
- const inserts = trees.filter((tree) => tree?.stmts?.[0]?.stmt?.InsertStmt).length;
101
- return inserts === 1 ? index : -1;
102
- }
@@ -1,8 +0,0 @@
1
- # Managed by starci app sync (slot app.db.image); do not edit: run "npx starci app sync --write".
2
- # The image of the db service of a lite app (ruling lite-official-postgres-image): the Docker Official postgres image, pinned by
3
- # digest, plus pgvector from the PGDG repository the image already configures. The contrib extensions (pgcrypto, pg_trgm, unaccent,
4
- # uuid-ossp, btree_gist) ship in the image. The stack's db service, `npm run db:create` and `starci app db drift` all build this file.
5
- FROM {{dbBaseImage}}
6
- RUN apt-get update \
7
- && apt-get install -y --no-install-recommends {{pgvectorPackage}} \
8
- && rm -rf /var/lib/apt/lists/*
@@ -1,24 +0,0 @@
1
- import type { EntityManager } from "typeorm"
2
-
3
- /** The database functions this app may call, each listed with its reason in ruleParams.be.dbFunctions or hfs.json sides.be.dbFunctions. */
4
- const IDENTIFIER = /^[a-z_][a-z0-9_]*(?:\.[a-z_][a-z0-9_]*)?$/
5
-
6
- /**
7
- * Calls one database function TypeORM cannot express (a pg built-in or a SECURITY DEFINER function of a migration) with numbered
8
- * parameters; the function name is an identifier the canon lint holds to the listed set, never a value.
9
- */
10
- export const callDbFunction = async <T>(manager: EntityManager, name: string, args: ReadonlyArray<unknown>): Promise<ReadonlyArray<T>> => {
11
- if (!IDENTIFIER.test(name)) throw new TypeError(`${name} is not a database function name`)
12
- const placeholders = args.map((_, index) => `$${String(index + 1)}`).join(", ")
13
- return manager.query<Array<T>>(`SELECT * FROM ${name}(${placeholders})`, [...args])
14
- }
15
-
16
- /** Takes a transaction-scoped advisory lock on the 64-bit hash of `key`; it is released when the manager's transaction ends. */
17
- export const advisoryXactLock = async (manager: EntityManager, key: string): Promise<void> => {
18
- await manager.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [key])
19
- }
20
-
21
- /** Sends one NOTIFY on `channel` with `payload`, delivered when the manager's transaction commits. */
22
- export const notify = async (manager: EntityManager, channel: string, payload: string): Promise<void> => {
23
- await manager.query("SELECT pg_notify($1, $2)", [channel, payload])
24
- }