@starci/hfs 4.11.13 → 4.11.16
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.
- package/CHANGELOG.md +28 -0
- package/db/migrate.mjs +1 -1
- package/db/postgres.mjs +38 -3
- package/package.json +1 -1
- package/release/index.mjs +4 -3
- package/release/lint-baseline.mjs +123 -0
- package/release/urgent.mjs +19 -4
- package/runtime/knowledge/hfs/canon-pins.yaml +8 -8
- package/runtime/knowledge/hfs/rules.yaml +4 -4
- package/runtime/knowledge/patterns/be/webhooks.yaml +3 -2
- package/runtime/scripts/hfs/architecture/identity.mjs +1 -1
- package/runtime/scripts/hfs/architecture/jwt-ast.mjs +14 -9
- package/runtime/scripts/hfs/rules/canon-copy-drift.mjs +54 -7
- package/scaffold/add-compose.mjs +3 -0
- package/scaffold/add-inbox-lite.mjs +45 -23
- package/scaffold/lite-exports.mjs +13 -0
- package/src/main.mjs +2 -2
- package/stack/cli.mjs +5 -3
- package/stack/deploy.mjs +6 -2
- package/stack/urgent-floor.mjs +101 -7
- package/templates/be/inbox/entities.index.ts.tpl +4 -0
- package/templates/be/inbox/entity.ts.tpl +29 -0
- package/templates/be/inbox/index.ts.tpl +1 -0
- package/templates/be/inbox/platform/inbox/inbox.decorators.ts.tpl +5 -2
- package/templates/be/inbox/platform/inbox/inbox.port.ts.tpl +29 -1
- package/templates/be/inbox/platform/inbox/inbox.service.spec.ts.tpl +65 -1
- package/templates/be/inbox/platform/inbox/inbox.service.ts.tpl +48 -10
- package/templates/be/inbox/platform/inbox/index.ts.tpl +2 -2
- package/templates/be/inbox/service.spec.ts.tpl +39 -15
- package/templates/be/inbox/service.ts.tpl +13 -13
- package/templates/be/inbox/webhook-door.spec.ts.tpl +4 -1
- package/templates/be/platform/errors-graphql/graphql-error.mapper.spec.ts +18 -8
- package/templates/be/platform/graphql/graphql-depth.policy.spec.ts +12 -3
- package/templates/be/platform/graphql/localize-errors.mapper.spec.ts +39 -17
- package/templates/be/skeleton/src/modules/domain/note/note.service.ts +3 -4
- package/templates/be/skeleton/src/modules/domain/note/persistence/note.rows.ts +4 -0
- package/templates/be/skeleton/src/modules/platform/database/errors/database.error.ts +3 -0
- package/templates/be/skeleton/src/modules/platform/database/index.ts +1 -0
- package/templates/be/skeleton/src/modules/platform/database/returned-rows.policy.spec.ts +84 -0
- package/templates/be/skeleton/src/modules/platform/database/returned-rows.policy.ts +61 -0
- package/templates/be/skeleton/src/modules/platform/http-security/http-security.decorators.ts +8 -1
- package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.spec.ts +121 -9
- package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.ts +12 -6
- package/templates/be/skeleton/src/modules/platform/http-security/persistence/rate-limit.rows.ts +5 -0
- package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.ts +3 -3
- package/templates/be/skeleton-lite/src/modules/platform/database/errors/database.error.ts +3 -0
- package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +1 -0
- package/templates/be/skeleton-lite/src/modules/platform/http-security/rate-limit.guard.spec.ts +41 -33
- package/templates/be/skeleton-lite/src/modules/platform/http-security/rate-limit.guard.ts +15 -9
- package/templates/be/inbox/database-client.spec.ts.tpl +0 -45
- package/templates/be/inbox/database-client.ts.tpl +0 -45
package/scaffold/add-compose.mjs
CHANGED
|
@@ -7,7 +7,10 @@ import { ScaffoldError } from './service.mjs';
|
|
|
7
7
|
export function addModuleImport(lines, { importLine, entry, relative, noun }) {
|
|
8
8
|
const active = compositionCode(lines.join('\n')).split('\n');
|
|
9
9
|
const imported = active.some(line => line.replace(/;\s*$/, '') === importLine);
|
|
10
|
+
// An area group spread once is registered whatever its argument (the lite root passes `tokens.verify` to domainModules).
|
|
11
|
+
const group = /^\.\.\.(\w+Modules)\(/.exec(entry)?.[1];
|
|
10
12
|
const registered = active.some(line => line.trim() === `${entry},` ||
|
|
13
|
+
(group !== undefined && line.includes(`...${group}(`)) ||
|
|
11
14
|
(/\bimports:\s*\[/.test(line) && !line.trim().startsWith('//') && line.replace(/\s+/g, '').includes(entry.replace(/\s+/g, ''))));
|
|
12
15
|
const lastImport = active.reduce((last, line, index) => line.startsWith('import ') ? index : last, -1);
|
|
13
16
|
const importsLine = line => /\bimports:\s*\[/.test(line) && !line.trim().startsWith('//');
|
|
@@ -13,6 +13,10 @@ const PLACEHOLDER = /@@([A-Za-z][A-Za-z0-9]*)@@/g;
|
|
|
13
13
|
const timestampOf = (now) =>
|
|
14
14
|
new Date(now()).toISOString().replace(/\D/g, "").slice(0, 14);
|
|
15
15
|
const upperOf = (name) => name.replaceAll("-", "_").toUpperCase();
|
|
16
|
+
const camelOf = (name) => {
|
|
17
|
+
const pascal = pascalOf(name);
|
|
18
|
+
return pascal.charAt(0).toLowerCase() + pascal.slice(1);
|
|
19
|
+
};
|
|
16
20
|
const snakeOf = (name) => name.replaceAll("-", "_");
|
|
17
21
|
|
|
18
22
|
const template = (relative, values) => {
|
|
@@ -72,8 +76,6 @@ const databasePatches = (root) => {
|
|
|
72
76
|
"lite add webhook needs the scaffolded platform/database capability",
|
|
73
77
|
);
|
|
74
78
|
let index = fs.readFileSync(indexFile, "utf8").replace(/\r\n/g, "\n");
|
|
75
|
-
if (!index.includes('export { recordWebhookDelivery, webhookReceiptInsert } from "./webhook-inbox.client"'))
|
|
76
|
-
index = `${index.trimEnd()}\nexport { recordWebhookDelivery, webhookReceiptInsert } from "./webhook-inbox.client"\n`;
|
|
77
79
|
if (!index.split("\n").some((line) => line.startsWith("export { ") && line.includes("DatabaseErrorCode") && line.endsWith(' } from "./errors/database.error"')))
|
|
78
80
|
index = `${index.trimEnd()}\nexport { DatabaseError, DatabaseErrorCode } from "./errors/database.error"\n`;
|
|
79
81
|
let error = withSqlIdentifierError(fs.readFileSync(errorFile, "utf8").replace(/\r\n/g, "\n"));
|
|
@@ -107,7 +109,15 @@ const handoffPlatformFiles = (root, values) => {
|
|
|
107
109
|
const planned = [];
|
|
108
110
|
for (const capability of ["inbox", "idempotency", "event-bus"]) {
|
|
109
111
|
const base = `be/src/modules/platform/${capability}`;
|
|
110
|
-
if (fs.existsSync(path.join(root, base, "index.ts")))
|
|
112
|
+
if (fs.existsSync(path.join(root, base, "index.ts"))) {
|
|
113
|
+
// The provider intake claims through the EntityInbox port; an inbox written before it cannot serve a new provider.
|
|
114
|
+
if (capability === "inbox" && !fs.readFileSync(path.join(root, base, "index.ts"), "utf8").includes("InjectEntityInbox"))
|
|
115
|
+
throw new ScaffoldError(
|
|
116
|
+
"HFS_ADD_INBOX_ENTITY_PORT",
|
|
117
|
+
`${base} predates the EntityInbox port (InjectEntityInbox, claimRow) the ${values.provider} intake claims through; bring it to the inbox of this hfs first`,
|
|
118
|
+
);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
111
121
|
const folder = path.join(TEMPLATES_DIR, "be", "inbox", "platform", capability);
|
|
112
122
|
for (const file of filesBelow(folder)) {
|
|
113
123
|
const relative = path.relative(folder, file).replaceAll("\\", "/");
|
|
@@ -149,6 +159,28 @@ const eventFiles = (root, provider, values) => {
|
|
|
149
159
|
return { planned, patches };
|
|
150
160
|
};
|
|
151
161
|
|
|
162
|
+
/** The lite entity list (BE-PERSISTENCE-3), where the provider inbox's `<provider>InboxEntities` is spread once. */
|
|
163
|
+
const PRIMARY_ENTITIES_FILE = ["be", "src", "modules", "platform", "database", "entities.ts"];
|
|
164
|
+
const PRIMARY_ENTITIES = /(export const PRIMARY_ENTITIES[^=]*=\s*\[)([\s\S]*?)(\])/;
|
|
165
|
+
|
|
166
|
+
/** entities.ts with the receipt entity list imported from the provider inbox barrel and spread into PRIMARY_ENTITIES. */
|
|
167
|
+
const primaryEntitiesPatch = (root, provider, list) => {
|
|
168
|
+
const file = path.join(root, ...PRIMARY_ENTITIES_FILE);
|
|
169
|
+
const relative = PRIMARY_ENTITIES_FILE.join("/");
|
|
170
|
+
if (!fs.existsSync(file))
|
|
171
|
+
throw new ScaffoldError("HFS_ADD_INBOX_DATABASE", `lite add webhook names ${list} in PRIMARY_ENTITIES of ${relative}, which is missing`);
|
|
172
|
+
const text = fs.readFileSync(file, "utf8").replace(/\r\n/g, "\n");
|
|
173
|
+
if (!PRIMARY_ENTITIES.test(text))
|
|
174
|
+
throw new ScaffoldError("HFS_ADD_INBOX_DATABASE", `lite add webhook names ${list} in PRIMARY_ENTITIES of ${relative}, which declares no PRIMARY_ENTITIES array`);
|
|
175
|
+
if (new RegExp(String.raw`\.\.\.${list}\b`).test(text)) return { file, text };
|
|
176
|
+
const withList = text.replace(PRIMARY_ENTITIES, (all, head, items, tail) =>
|
|
177
|
+
`${head}${[...items.split(",").map((item) => item.trim()).filter(Boolean), `...${list}`].join(", ")}${tail}`);
|
|
178
|
+
const lines = withList.split("\n");
|
|
179
|
+
const lastImport = lines.reduce((at, line, index) => (line.startsWith("import ") ? index : at), -1);
|
|
180
|
+
lines.splice(lastImport + 1, 0, `import { ${list} } from "@modules/domain/${provider}-inbox"`);
|
|
181
|
+
return { file, text: lines.join("\n") };
|
|
182
|
+
};
|
|
183
|
+
|
|
152
184
|
const eventPatternPatch = (root) => {
|
|
153
185
|
const file = path.join(root, "hfs.json");
|
|
154
186
|
const declaration = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
@@ -190,6 +222,7 @@ export function addLiteWebhookInbox({
|
|
|
190
222
|
...values,
|
|
191
223
|
provider,
|
|
192
224
|
Provider,
|
|
225
|
+
providerCamel: camelOf(provider),
|
|
193
226
|
providerUpper: upperOf(provider),
|
|
194
227
|
table,
|
|
195
228
|
};
|
|
@@ -221,6 +254,14 @@ export function addLiteWebhookInbox({
|
|
|
221
254
|
relative: `${base}/${provider}-inbox.service.spec.ts`,
|
|
222
255
|
text: template("service.spec.ts.tpl", renderValues),
|
|
223
256
|
},
|
|
257
|
+
{
|
|
258
|
+
relative: `${base}/persistence/entities/${provider}-inbox.entity.ts`,
|
|
259
|
+
text: template("entity.ts.tpl", renderValues),
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
relative: `${base}/persistence/entities/index.ts`,
|
|
263
|
+
text: template("entities.index.ts.tpl", renderValues),
|
|
264
|
+
},
|
|
224
265
|
{
|
|
225
266
|
relative: `be/src/modules/events/${provider}/${provider}-delivery-recorded.event.ts`,
|
|
226
267
|
text: template("delivery-recorded.event.ts.tpl", renderValues),
|
|
@@ -242,26 +283,6 @@ export function addLiteWebhookInbox({
|
|
|
242
283
|
text: render(fs.readFileSync(path.join(TEMPLATES_DIR, "be", "skeleton-lite", "src", "modules", "platform", "database", file), "utf8"), {}),
|
|
243
284
|
});
|
|
244
285
|
}
|
|
245
|
-
const databaseClient = path.join(
|
|
246
|
-
root,
|
|
247
|
-
"be",
|
|
248
|
-
"src",
|
|
249
|
-
"modules",
|
|
250
|
-
"platform",
|
|
251
|
-
"database",
|
|
252
|
-
"webhook-inbox.client.ts",
|
|
253
|
-
);
|
|
254
|
-
if (!fs.existsSync(databaseClient))
|
|
255
|
-
planned.push(
|
|
256
|
-
{
|
|
257
|
-
relative: "be/src/modules/platform/database/webhook-inbox.client.ts",
|
|
258
|
-
text: template("database-client.ts.tpl", renderValues),
|
|
259
|
-
},
|
|
260
|
-
{
|
|
261
|
-
relative: "be/src/modules/platform/database/webhook-inbox.client.spec.ts",
|
|
262
|
-
text: template("database-client.spec.ts.tpl", renderValues),
|
|
263
|
-
},
|
|
264
|
-
);
|
|
265
286
|
const clashes = planned
|
|
266
287
|
.filter((entry) => fs.existsSync(path.join(root, ...entry.relative.split("/"))))
|
|
267
288
|
.map((entry) => entry.relative);
|
|
@@ -272,6 +293,7 @@ export function addLiteWebhookInbox({
|
|
|
272
293
|
);
|
|
273
294
|
const patches = [
|
|
274
295
|
...databasePatches(root),
|
|
296
|
+
primaryEntitiesPatch(root, provider, `${renderValues.providerCamel}InboxEntities`),
|
|
275
297
|
...events.patches,
|
|
276
298
|
eventPatternPatch(root),
|
|
277
299
|
];
|
|
@@ -108,6 +108,19 @@ const REGISTRATIONS = Object.freeze({
|
|
|
108
108
|
source: "./webhook-signature.service",
|
|
109
109
|
names: ["WebhookSignatureService"],
|
|
110
110
|
},
|
|
111
|
+
{
|
|
112
|
+
// The webhook door types its request with the correlation the logging middleware stamps on it.
|
|
113
|
+
file: "be/src/modules/platform/logging/index.ts",
|
|
114
|
+
source: "./correlation.contracts",
|
|
115
|
+
names: ["CorrelationRequest"],
|
|
116
|
+
type: true,
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
// The lite event bus refuses an envelope whose correlation id is not one the logging capability issues.
|
|
120
|
+
file: "be/src/modules/platform/logging/index.ts",
|
|
121
|
+
source: "./correlation.policy",
|
|
122
|
+
names: ["isCorrelationId"],
|
|
123
|
+
},
|
|
111
124
|
],
|
|
112
125
|
"webhook-full": [
|
|
113
126
|
{
|
package/src/main.mjs
CHANGED
|
@@ -116,11 +116,11 @@ starci app db migrate [--execute] [--url-env <NAME>] [--json] [--cwd <dir>]
|
|
|
116
116
|
starci app db post-cutover [--execute] [--green-at <ISO> | --owner-confirms-old-app-gone] [--url-env <NAME>] [--json] [--cwd <dir>]
|
|
117
117
|
starci app db drift [--url-env <NAME>] [--json] [--cwd <dir>]
|
|
118
118
|
starci app release gate [--execute] [--source <ref>] [--tag-prefix <prefix>] [--backfill <tag>] [--json] [--cwd <dir>]
|
|
119
|
-
starci app release push [--execute] [--remote <name>] [--urgent --reason <text>] [--json] [--cwd <dir>]
|
|
119
|
+
starci app release push [--execute] [--remote <name>] [--urgent --reason <text> [--lint-baseline <tag>]] [--json] [--cwd <dir>]
|
|
120
120
|
starci app upgrade [--plan] [--cwd <dir>]
|
|
121
121
|
starci app stack <up|down|status> [--stack <dir>] [--services <list>] [--k3d] [--force] [--json] [--cwd <dir>]
|
|
122
122
|
starci app stack <prepare|doctor|connect|disconnect|build-args|render|deploy|converge|verify|rollback|prune|backup|jobs> --env <env> [--execute] [--json] [--cwd <dir>]
|
|
123
|
-
starci app stack deploy --env <env> --source-sha <sha> --urgent [--db-url-env <NAME>] [--backup-out <dir>] [--execute] [--json] [--cwd <dir>]
|
|
123
|
+
starci app stack deploy --env <env> --source-sha <sha> --urgent [--db-url-env <NAME>] [--backup-out <dir>] [--first-start --db-service <service>] [--execute] [--json] [--cwd <dir>]
|
|
124
124
|
starci app explain <path> [--cwd <dir>] [--json]
|
|
125
125
|
starci app emit [--cwd <dir>]
|
|
126
126
|
starci app new service <dir> <name> [--inject <binding>...] [--cwd <dir>]
|
package/stack/cli.mjs
CHANGED
|
@@ -14,8 +14,8 @@ const DOCKER_ACTIONS = new Set(['deploy', 'accept', 'converge', 'verify', 'rollb
|
|
|
14
14
|
|
|
15
15
|
export const ACTIONS = Object.freeze(['prepare', 'doctor', 'connect', 'disconnect', 'build-args', 'render', 'deploy', 'converge', 'verify', 'rollback', 'prune', 'backup', 'jobs', 'task', 'ingress', 'retire', 'build', 'accept']);
|
|
16
16
|
|
|
17
|
-
const BOOLEAN = new Set(['--execute', '--json', '--swarm-state', '--init-swarm', '--owner-approved', '--fix-live-restore', '--skip-public', '--skip-internal', '--allow-unpinned', '--push', '--urgent']);
|
|
18
|
-
const VALUE = new Set(['--env', '--image', '--source-sha', '--previous', '--out', '--release', '--since', '--timeout', '--retry', '--keep', '--app', '--ssh-cmd', '--extra-compose', '--project', '--target-binding', '--db-url-env', '--backup-out']);
|
|
17
|
+
const BOOLEAN = new Set(['--execute', '--json', '--swarm-state', '--init-swarm', '--owner-approved', '--fix-live-restore', '--skip-public', '--skip-internal', '--allow-unpinned', '--push', '--urgent', '--first-start']);
|
|
18
|
+
const VALUE = new Set(['--env', '--image', '--source-sha', '--previous', '--out', '--release', '--since', '--timeout', '--retry', '--keep', '--app', '--ssh-cmd', '--extra-compose', '--project', '--target-binding', '--db-url-env', '--backup-out', '--db-service']);
|
|
19
19
|
const REPEAT = new Set(['--image', '--keep', '--extra-compose', '--project']);
|
|
20
20
|
|
|
21
21
|
/** Splits argv into { flags, positional }; an unknown flag or a flag without its value is a refusal. */
|
|
@@ -72,6 +72,7 @@ const OPTIONS = {
|
|
|
72
72
|
images: parseImages(flags.image), sourceSha: flags['source-sha'], execute: flags.execute === true,
|
|
73
73
|
out: flags.out ? path.resolve(cwd, flags.out) : null, timeoutSeconds: number(flags, 'timeout'), retrySeconds: number(flags, 'retry'), skipPublic: flags['skip-public'] === true,
|
|
74
74
|
urgent: flags.urgent === true, dbUrlEnv: flags['db-url-env'], backupOut: flags['backup-out'] ? path.resolve(cwd, flags['backup-out']) : undefined,
|
|
75
|
+
firstStart: flags['first-start'] === true, dbService: flags['db-service'],
|
|
75
76
|
}),
|
|
76
77
|
converge: ({ flags }) => ({ since: flags.since, timeoutSeconds: number(flags, 'timeout') }),
|
|
77
78
|
verify: ({ flags }) => ({ retrySeconds: number(flags, 'retry'), skipPublic: flags['skip-public'] === true, skipInternal: flags['skip-internal'] === true }),
|
|
@@ -126,7 +127,8 @@ export async function stackMain(argv, io = {}) {
|
|
|
126
127
|
if (binding && (!['deploy', 'verify'].includes(action) || flags['ssh-cmd'] || flags['skip-public'] || flags['skip-internal']))
|
|
127
128
|
throw refusal('STACK_USAGE', 'the bound native consumer supports only complete deploy or verify');
|
|
128
129
|
const config = bindStackTarget(ctx, loadDeployConfig({ repoRoot: ctx.cwd, env: flags.env ?? 'production' }), binding);
|
|
129
|
-
if (action !== 'deploy' && (flags.urgent || flags['db-url-env'] || flags['backup-out'])) throw refusal('STACK_USAGE', '--urgent, --db-url-env
|
|
130
|
+
if (action !== 'deploy' && (flags.urgent || flags['db-url-env'] || flags['backup-out'] || flags['first-start'] || flags['db-service'])) throw refusal('STACK_USAGE', '--urgent, --db-url-env, --backup-out, --first-start and --db-service belong to deploy');
|
|
131
|
+
if (!flags.urgent && (flags['first-start'] || flags['db-service'])) throw refusal('STACK_USAGE', '--first-start and --db-service are the first start of an empty database under the urgent floor: add --urgent');
|
|
130
132
|
if (action === 'deploy' && !flags.urgent && (flags['db-url-env'] || flags['backup-out'])) throw refusal('STACK_USAGE', '--db-url-env and --backup-out choose the urgent floor\'s database and backup folder: add --urgent');
|
|
131
133
|
const options = OPTIONS[action]({ flags, positional, cwd: ctx.cwd });
|
|
132
134
|
const handler = await handlerOf(action, io.importer ?? ((file) => import(file)));
|
package/stack/deploy.mjs
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// after the stack deploy redeploys the previous accepted release from its STORED bytes; a failure before it leaves the services untouched.
|
|
4
4
|
// Without `execute` a deploy renders locally and prints the plan: NO remote call at all. A rollback cannot undo data effects.
|
|
5
5
|
// An urgent deploy (`urgent`, owner ruling urgent-release 2026-10-29) runs the safety floor of urgent-floor.mjs first (fresh verified encrypted
|
|
6
|
-
// backup with a test decrypt, rollback target, migrate preview then execute) and refuses with the floor's own code when a step of it fails
|
|
6
|
+
// backup with a test decrypt, rollback target, migrate preview then execute) and refuses with the floor's own code when a step of it fails; its
|
|
7
|
+
// first start (`firstStart`) proves the database empty and migrates it inside its own container, a `first-start` step between edge and converge.
|
|
7
8
|
//
|
|
8
9
|
// `deps` are the collaborators, all factories or functions with the signature of their module, defaulting to lazy dynamic imports so this
|
|
9
10
|
// module never needs render.mjs / provision.mjs / remote.mjs at load time (the specs inject stubs):
|
|
@@ -197,6 +198,9 @@ async function deployExecute(ctx, config, options, d, out, cleanups) {
|
|
|
197
198
|
const failed = await runSteps([
|
|
198
199
|
['deploy', () => stackDeploy(ctx, config, path.join(out, 'stack.yaml'))],
|
|
199
200
|
['edge', async () => d.reconcileEdge(ctx, config, path.join(out, 'stack.yaml'))],
|
|
201
|
+
// The first start of a self-hosted database (urgent-floor.mjs): its schema is bootstrapped and migrated once the database runs, before the
|
|
202
|
+
// services that need it are waited for.
|
|
203
|
+
...(options.firstStartStep ? [['first-start', options.firstStartStep]] : []),
|
|
200
204
|
['converge', () => d.convergeStack(ctx, config, { since, timeoutSeconds: options.timeoutSeconds })],
|
|
201
205
|
...(config.postDeploy.length > 0 ? [['post-deploy', () => d.postDeployStack(ctx, config)]] : []),
|
|
202
206
|
['verify', () => d.verifyStack(ctx, config, { retrySeconds: options.retrySeconds, skipPublic: skipPublic === true, stackFile: path.join(out, 'stack.yaml') })],
|
|
@@ -245,7 +249,7 @@ export async function deployRelease(ctx, config, options = {}, deps = {}) {
|
|
|
245
249
|
};
|
|
246
250
|
if (options.urgent === true) {
|
|
247
251
|
return await urgentDeploy(ctx, config, options, {
|
|
248
|
-
render, dryRun, openStore: () => openStore(ctx, config, options, d, cleanups), deployExecute: () => deployExecute(ctx, config, options, d, out, cleanups), sha256: fileSha256,
|
|
252
|
+
render, dryRun, openStore: () => openStore(ctx, config, options, d, cleanups), deployExecute: (extra = {}) => deployExecute(ctx, config, { ...options, ...extra }, d, out, cleanups), sha256: fileSha256,
|
|
249
253
|
}, d.floor);
|
|
250
254
|
}
|
|
251
255
|
if (options.execute !== true) return await dryRun();
|
package/stack/urgent-floor.mjs
CHANGED
|
@@ -10,13 +10,23 @@
|
|
|
10
10
|
// deploy the release with its readiness verify (deploy.verify); red rolls back by itself STACK_URGENT_RED_ROLLED_BACK, STACK_URGENT_ROLLBACK_FAILED,
|
|
11
11
|
// STACK_URGENT_DEPLOY_NOT_STARTED (red before the stack deploy)
|
|
12
12
|
// The public-host checks are never skipped (STACK_URGENT_SKIP_PUBLIC). A rollback cannot undo the migrations: the backup above is their way back.
|
|
13
|
+
// The first start (`--first-start --db-service <service>`, owner ruling 2026-10-29) is the floor of a self-hosted database the release itself
|
|
14
|
+
// starts: there is nothing to back up and nothing reaches it before the stack runs it, so instead of backup, decrypt and migrate before the deploy
|
|
15
|
+
// the release deploys and, between edge and converge, the floor waits for the database service's container to accept connections, proves the
|
|
16
|
+
// database empty (no migration history, no table in public) and runs migrate preview then execute inside that container (db/bootstrap.sql first,
|
|
17
|
+
// as the --db-url-env role). Every other step holds: record, rollback target, readiness verify, automatic rollback. Each failure is its own code:
|
|
18
|
+
// STACK_FIRST_START_USAGE (no --db-service), STACK_FIRST_START_NO_DATABASE (no container accepted connections in time),
|
|
19
|
+
// STACK_FIRST_START_NOT_EMPTY (a history or a public table: use the plain urgent floor), STACK_FIRST_START_MIGRATE_RED (preview or execute red);
|
|
20
|
+
// each rolls the release back to the previous one; the migrations a red first start applied stay in a database no release used before.
|
|
13
21
|
// `deps` are the collaborators (backup, decrypt, migrate), each defaulting to the app's own db verbs; a spec injects stubs.
|
|
14
22
|
import fs from 'node:fs';
|
|
15
23
|
import os from 'node:os';
|
|
16
24
|
import path from 'node:path';
|
|
17
25
|
import { readDeclaration } from '../release/index.mjs';
|
|
18
26
|
import { releaseDir } from '../release/gate.mjs';
|
|
27
|
+
import { DEFAULT_DB_URL_ENV, dbTarget, inContainer, queryRows } from '../db/postgres.mjs';
|
|
19
28
|
import { readUrgent } from '../release/records.mjs';
|
|
29
|
+
import { lines as splitLines, tool } from './context.mjs';
|
|
20
30
|
import { refusal } from './errors.mjs';
|
|
21
31
|
|
|
22
32
|
const AGE_HEADER = 'age-encryption.org/v1\n';
|
|
@@ -54,7 +64,28 @@ async function defaultMigrate(ctx, { target, execute }) {
|
|
|
54
64
|
return migrateDatabase(armed, loadDatabase(ctx.cwd), { ...target, execute });
|
|
55
65
|
}
|
|
56
66
|
|
|
57
|
-
|
|
67
|
+
/** How long a first start waits for the database service's container to accept connections. */
|
|
68
|
+
export const FIRST_START_WAIT_SECONDS = 180;
|
|
69
|
+
|
|
70
|
+
/** The running container of `service` whose server accepts connections (pg_isready), polled until `seconds` pass; undefined when none did. */
|
|
71
|
+
async function defaultDbContainer(ctx, config, { service, seconds }) {
|
|
72
|
+
const deadline = ctx.now() + seconds * 1000;
|
|
73
|
+
for (;;) {
|
|
74
|
+
const found = tool(ctx, 'docker', ['ps', '-q', '--filter', `label=com.docker.swarm.service.name=${config.stack}_${service}`, '--filter', 'status=running']);
|
|
75
|
+
const id = splitLines(found.stdout)[0];
|
|
76
|
+
if (id && tool(ctx, 'docker', ['exec', id, 'pg_isready', '-q']).status === 0) return id;
|
|
77
|
+
if (ctx.now() >= deadline) return undefined;
|
|
78
|
+
await new Promise((resolve) => { setTimeout(resolve, 3000); });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Whether the target database is empty: no migration history table and no table in public. */
|
|
83
|
+
async function defaultEmptiness(ctx, target) {
|
|
84
|
+
const [row] = queryRows(ctx, target, "select to_regclass('supabase_migrations.schema_migrations') is not null as history, (select count(*)::int from pg_tables where schemaname = 'public') as public_tables", { withhold: true });
|
|
85
|
+
return { empty: row?.history === false && row?.public_tables === 0, history: row?.history === true, publicTables: Number(row?.public_tables ?? -1) };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const FLOOR_DEPS = Object.freeze({ backup: defaultBackup, decrypt: defaultDecrypt, migrate: defaultMigrate, dbContainer: defaultDbContainer, emptiness: defaultEmptiness });
|
|
58
89
|
|
|
59
90
|
/** A refused floor step: the lines so far and the code; the CLI prints both and exits 2. */
|
|
60
91
|
const refused = (lines, code, message, data = {}) => ({ ok: false, refused: { code, message }, lines, data: { ...data, refused: code } });
|
|
@@ -83,6 +114,11 @@ function preconditions(ctx, config, options) {
|
|
|
83
114
|
if (!record) throw refusal('STACK_URGENT_NO_RECORD', `${options.sourceSha} has no urgent record: push it with starci app release push --urgent --reason "<text>" --execute first`);
|
|
84
115
|
if (record.pushed !== true) throw refusal('STACK_URGENT_NOT_PUSHED', `the urgent release ${record.tag} of ${options.sourceSha.slice(0, 10)} was never pushed`);
|
|
85
116
|
if (!readDeclaration(config.repoRoot ?? ctx.cwd).database) throw refusal('STACK_URGENT_NO_DATABASE', 'the floor backs up and migrates the database the `database:` block declares, and this app declares none');
|
|
117
|
+
if (options.firstStart === true) {
|
|
118
|
+
// A first start backs nothing up, so it needs no identity for a test decrypt; it needs the service its database runs as.
|
|
119
|
+
if (!options.dbService) throw refusal('STACK_FIRST_START_USAGE', '--first-start names the stack service of the database it bootstraps: add --db-service <service>');
|
|
120
|
+
return { record, identity: null };
|
|
121
|
+
}
|
|
86
122
|
const identity = ctx.env.SOPS_AGE_KEY_FILE;
|
|
87
123
|
if (!identity || !fs.existsSync(identity)) throw refusal('STACK_URGENT_DECRYPT_UNAVAILABLE', 'the test decrypt of the backup reads the age identity named by SOPS_AGE_KEY_FILE, which is not set or not a file');
|
|
88
124
|
return { record, identity };
|
|
@@ -122,6 +158,57 @@ async function guard(ctx, config, options, { f, run, identity, lines }) {
|
|
|
122
158
|
return null;
|
|
123
159
|
}
|
|
124
160
|
|
|
161
|
+
/**
|
|
162
|
+
* The `first-start` step of a first start's deploy (between edge and converge): the database container, the proof it is empty, migrate preview
|
|
163
|
+
* then execute inside it. Returns { ok, lines, code } where code names the failure.
|
|
164
|
+
*/
|
|
165
|
+
async function firstStartStep(ctx, config, options, f) {
|
|
166
|
+
const service = `${config.stack}_${options.dbService}`;
|
|
167
|
+
const container = await f.dbContainer(ctx, config, { service: options.dbService, seconds: FIRST_START_WAIT_SECONDS });
|
|
168
|
+
if (!container) return { ok: false, code: 'STACK_FIRST_START_NO_DATABASE', lines: [`first start: no running container of ${service} accepted connections within ${FIRST_START_WAIT_SECONDS}s`] };
|
|
169
|
+
const target = inContainer(dbTarget(ctx, options.dbUrlEnv ?? DEFAULT_DB_URL_ENV), container);
|
|
170
|
+
const state = await f.emptiness(ctx, target);
|
|
171
|
+
if (!state.empty) {
|
|
172
|
+
const found = state.history ? 'a migration history' : `${state.publicTables < 0 ? 'an unreadable count of' : state.publicTables} table(s) in public`;
|
|
173
|
+
return { ok: false, code: 'STACK_FIRST_START_NOT_EMPTY', lines: [`first start refused: the database of ${service} holds ${found}; a database with data takes the plain urgent floor (backup first)`] };
|
|
174
|
+
}
|
|
175
|
+
const lines = [`first start: the database of ${service} is empty (no migration history, no table in public): nothing to back up`];
|
|
176
|
+
for (const execute of [false, true]) {
|
|
177
|
+
let result;
|
|
178
|
+
try { result = await f.migrate(ctx, { target: { target }, execute }); } catch (error) { result = { ok: false, lines: [String(error?.message ?? error)] }; }
|
|
179
|
+
lines.push(...(result.lines ?? []).map((line) => `first start: migrate${execute ? '' : ' preview'}: ${line}`));
|
|
180
|
+
if (!result.ok) return { ok: false, code: 'STACK_FIRST_START_MIGRATE_RED', lines: [...lines, `first start: the migration${execute ? '' : ' preview'} is red`] };
|
|
181
|
+
}
|
|
182
|
+
return { ok: true, lines };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* A first start: the rollback target, then the deploy whose `first-start` step bootstraps and migrates the empty database. Returns the dry-run
|
|
187
|
+
* plan or a refused result, or { deployed } for the verdicts it shares with the plain floor.
|
|
188
|
+
*/
|
|
189
|
+
async function firstStartDeploy(ctx, config, options, run, f, lines) {
|
|
190
|
+
if (options.execute !== true) {
|
|
191
|
+
const plan = await run.dryRun();
|
|
192
|
+
return { ...plan, lines: [...lines, `floor (never skipped), first start of ${config.stack}_${options.dbService}: rollback target, deploy, then before converge: wait for the database, prove it empty, migrate preview then execute inside its container; readiness verify and automatic rollback`, ...plan.lines] };
|
|
193
|
+
}
|
|
194
|
+
const { releaseId } = await run.render();
|
|
195
|
+
lines.push(`floor: release ${releaseId} rendered`);
|
|
196
|
+
const store = await run.openStore();
|
|
197
|
+
if (!(await store.current())) return refused(lines, 'STACK_URGENT_NO_ROLLBACK_TARGET', 'no accepted previous release is stored on the manager, so a red first start could not roll back; nothing was deployed');
|
|
198
|
+
let code = null;
|
|
199
|
+
const deployed = await run.deployExecute({
|
|
200
|
+
firstStartStep: async () => {
|
|
201
|
+
const step = await firstStartStep(ctx, config, options, f);
|
|
202
|
+
code = step.code ?? null;
|
|
203
|
+
return step;
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
if (!deployed.ok && code) {
|
|
207
|
+
return refused([...lines, ...deployed.lines], code, `the first start failed; ${deployed.data?.rolledBack ? `the release was rolled back to ${deployed.data.previous}` : 'the rollback did not converge: follow the stack runbook, Rollback'}`, deployed.data);
|
|
208
|
+
}
|
|
209
|
+
return { deployed };
|
|
210
|
+
}
|
|
211
|
+
|
|
125
212
|
/**
|
|
126
213
|
* An urgent deploy: the floor's preconditions, then (with execute) its guard steps, then `deployExecute` (the release, its readiness verify and the
|
|
127
214
|
* automatic rollback). `run` = { render(), openStore(), deployExecute(), dryRun(), sha256 }. Returns { ok, lines, data } or a refused result whose
|
|
@@ -131,13 +218,20 @@ export async function urgentDeploy(ctx, config, options, run, deps = {}) {
|
|
|
131
218
|
const f = { ...FLOOR_DEPS, ...deps };
|
|
132
219
|
const { record, identity } = preconditions(ctx, config, options);
|
|
133
220
|
const lines = [`urgent ${record.tag} (${options.sourceSha.slice(0, 10)}) by ${record.role}: ${record.reason}`];
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
221
|
+
let deployed;
|
|
222
|
+
if (options.firstStart === true) {
|
|
223
|
+
const first = await firstStartDeploy(ctx, config, options, run, f, lines);
|
|
224
|
+
if (!first.deployed) return { ...first, data: { ...first.data, urgent: record.tag, firstStart: true } };
|
|
225
|
+
deployed = first.deployed;
|
|
226
|
+
} else {
|
|
227
|
+
if (options.execute !== true) {
|
|
228
|
+
const plan = await run.dryRun();
|
|
229
|
+
return { ...plan, lines: [...lines, 'floor (never skipped): fresh encrypted database backup, receipt verify, test decrypt, rollback target, migrate preview then execute, deploy with readiness verify and automatic rollback', ...plan.lines] };
|
|
230
|
+
}
|
|
231
|
+
const stop = await guard(ctx, config, options, { f, run, identity, lines });
|
|
232
|
+
if (stop) return { ...stop, data: { ...stop.data, urgent: record.tag } };
|
|
233
|
+
deployed = await run.deployExecute();
|
|
137
234
|
}
|
|
138
|
-
const stop = await guard(ctx, config, options, { f, run, identity, lines });
|
|
139
|
-
if (stop) return { ...stop, data: { ...stop.data, urgent: record.tag } };
|
|
140
|
-
const deployed = await run.deployExecute();
|
|
141
235
|
const all = [...lines, ...deployed.lines];
|
|
142
236
|
if (deployed.ok) return { ...deployed, lines: all, data: { ...deployed.data, urgent: record.tag } };
|
|
143
237
|
const data = { ...deployed.data, urgent: record.tag };
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm"
|
|
2
|
+
|
|
3
|
+
@Entity({ schema: "public", name: "@@table@@" })
|
|
4
|
+
/** One @@provider@@ delivery receipt, a row of public.@@table@@ from its inbox migration in db/migrations; a reactor or job consumes it after commit. */
|
|
5
|
+
export class @@Provider@@InboxEntity {
|
|
6
|
+
/** The receipt id the winning claim answers. */
|
|
7
|
+
@PrimaryGeneratedColumn("uuid", { name: "id" })
|
|
8
|
+
id!: string
|
|
9
|
+
|
|
10
|
+
/** The provider the receipt belongs to; the table admits only @@provider@@. */
|
|
11
|
+
@Column({ name: "provider", type: "text" })
|
|
12
|
+
provider!: string
|
|
13
|
+
|
|
14
|
+
/** The provider's delivery id, unique with the provider. */
|
|
15
|
+
@Column({ name: "delivery_id", type: "text" })
|
|
16
|
+
deliveryId!: string
|
|
17
|
+
|
|
18
|
+
/** When the delivery was recorded; the database sets it. */
|
|
19
|
+
@Column({ name: "received_at", type: "timestamptz", insert: false, update: false })
|
|
20
|
+
receivedAt!: Date
|
|
21
|
+
|
|
22
|
+
/** The verified delivery body the consumer reads; it carries no credential. */
|
|
23
|
+
@Column({ name: "payload", type: "jsonb" })
|
|
24
|
+
payload!: object
|
|
25
|
+
|
|
26
|
+
/** When the consumer finished the delivery; null while it is pending. */
|
|
27
|
+
@Column({ name: "processed_at", type: "timestamptz", nullable: true })
|
|
28
|
+
processedAt!: Date | null
|
|
29
|
+
}
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { injector } from "@modules/platform/composition"
|
|
2
2
|
import type { TypedParameterDecorator } from "@modules/platform/composition"
|
|
3
|
-
import type { Inbox } from "./inbox.port"
|
|
3
|
+
import type { EntityInbox, Inbox } from "./inbox.port"
|
|
4
4
|
|
|
5
|
-
/** Token of the shared, transaction-owned inbox claims. */
|
|
5
|
+
/** Token of the shared, transaction-owned inbox claims; its provider is an EntityInbox too. */
|
|
6
6
|
export const INBOX: unique symbol = Symbol("platform.inbox")
|
|
7
7
|
|
|
8
8
|
/** Injects the Inbox port. Parameter type: Inbox. */
|
|
9
9
|
export const InjectInbox = (): TypedParameterDecorator<Inbox> => injector<Inbox>(INBOX)
|
|
10
|
+
|
|
11
|
+
/** Injects the Inbox port of a capability whose provider receipt is an entity row. Parameter type: EntityInbox. */
|
|
12
|
+
export const InjectEntityInbox = (): TypedParameterDecorator<EntityInbox> => injector<EntityInbox>(INBOX)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { EntityManager } from "typeorm"
|
|
1
|
+
import type { EntityManager, EntityTarget, ObjectLiteral, QueryDeepPartialEntity } from "typeorm"
|
|
2
2
|
|
|
3
3
|
/** The authenticated scope and complete normalized body of a client operation; the key is never part of the body. */
|
|
4
4
|
export interface ClientOperation {
|
|
@@ -27,3 +27,31 @@ export interface Inbox {
|
|
|
27
27
|
/** Deletes one bounded batch of completed client claims older than the cutoff. */
|
|
28
28
|
sweepClients(cutoff: Date, tx: EntityManager): Promise<number>
|
|
29
29
|
}
|
|
30
|
+
|
|
31
|
+
/** A provider's receipt row: its entity, mapping a table with an `id` column, and the complete row, delivery key and payload included. */
|
|
32
|
+
export interface InboxEntityReceipt<Row extends ObjectLiteral> {
|
|
33
|
+
/** The provider's receipt entity. */
|
|
34
|
+
readonly entity: EntityTarget<Row>
|
|
35
|
+
/**
|
|
36
|
+
* The row the receipt holds, built from the delivery id the claim normalized (trimmed, never missing), so the receipt keys
|
|
37
|
+
* exactly what was claimed; the deferred consumer reads it, so it carries no credential.
|
|
38
|
+
*/
|
|
39
|
+
readonly row: (deliveryId: string) => QueryDeepPartialEntity<Row>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The id of the receipt row a winning claim inserted; undefined when the delivery was a repeat or its receipt already existed. */
|
|
43
|
+
export type InboxReceiptId = string | undefined
|
|
44
|
+
|
|
45
|
+
/** The inbox of a capability whose provider receipt is an entity row, so it records the receipt without raw SQL. */
|
|
46
|
+
export interface EntityInbox extends Inbox {
|
|
47
|
+
/**
|
|
48
|
+
* Claims once per source and event id, then inserts the receipt row, both in the caller transaction. Answers the id the
|
|
49
|
+
* winning insert returned; a repeated delivery, or a receipt row that already exists, inserts nothing and answers undefined.
|
|
50
|
+
*/
|
|
51
|
+
claimRow<Row extends ObjectLiteral>(
|
|
52
|
+
source: string,
|
|
53
|
+
eventId: InboxEventId,
|
|
54
|
+
tx: EntityManager,
|
|
55
|
+
receipt: InboxEntityReceipt<Row>,
|
|
56
|
+
): Promise<InboxReceiptId>
|
|
57
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { IdempotencyErrorCode } from "@modules/platform/idempotency"
|
|
2
2
|
import { Test } from "@nestjs/testing"
|
|
3
3
|
import type { ClientOperation } from "./inbox.port"
|
|
4
|
-
import { FakeClock, fakeTransaction, mockEntityManager } from "@starci/jest-preset"
|
|
4
|
+
import { FakeClock, fakeTransaction, mockEntityManager, queueQueryBuilders } from "@starci/jest-preset"
|
|
5
5
|
import { BATCH_ROWS, DatabaseError, DatabaseErrorCode } from "@modules/platform/database"
|
|
6
6
|
import { CLOCK } from "@modules/platform/clock"
|
|
7
7
|
import { InboxError, InboxErrorCode } from "./errors/inbox.error"
|
|
@@ -29,6 +29,15 @@ const REQUEST = {
|
|
|
29
29
|
}
|
|
30
30
|
const OUTCOME = { kind: "ok", value: { orderId: "order-1" } }
|
|
31
31
|
|
|
32
|
+
/** A provider receipt entity as a capability maps its inbox table. */
|
|
33
|
+
class SpecReceiptEntity {
|
|
34
|
+
id!: string
|
|
35
|
+
provider!: string
|
|
36
|
+
deliveryId!: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const RECEIPT = { entity: SpecReceiptEntity, row: (deliveryId: string) => ({ provider: "spec", deliveryId }) }
|
|
40
|
+
|
|
32
41
|
const build = async () => {
|
|
33
42
|
const module = await Test.createTestingModule({
|
|
34
43
|
providers: [PostgresInbox, { provide: CLOCK, useValue: new FakeClock("2026-02-03T04:05:06.000Z") }],
|
|
@@ -94,6 +103,61 @@ describe("PostgresInbox delivery rollback and validation", () => {
|
|
|
94
103
|
})
|
|
95
104
|
})
|
|
96
105
|
|
|
106
|
+
describe("PostgresInbox entity receipts", () => {
|
|
107
|
+
it("claims the trimmed delivery id, then inserts the receipt row it keys in the caller's transaction and answers its id", async () => {
|
|
108
|
+
const subject = await build()
|
|
109
|
+
const manager = mockEntityManager({ query: [CLAIM_EVENT, [{ event_id: "delivery-1" }]] })
|
|
110
|
+
const [insert] = queueQueryBuilders(manager, [{ execute: { raw: [{ id: "receipt-1" }] } }])
|
|
111
|
+
|
|
112
|
+
await expect(subject.claimRow("webhook:spec", " delivery-1 ", manager, RECEIPT)).resolves.toBe("receipt-1")
|
|
113
|
+
|
|
114
|
+
expect(manager.query).toHaveBeenCalledWith(CLAIM_EVENT, ["webhook:spec", "delivery-1"])
|
|
115
|
+
expect(manager.createQueryBuilder).toHaveBeenCalledWith(SpecReceiptEntity, "receipt")
|
|
116
|
+
expect(insert?.callsOf("values")).toEqual([[{ provider: "spec", deliveryId: "delivery-1" }]])
|
|
117
|
+
expect(insert?.callsOf("orIgnore")).toEqual([[]])
|
|
118
|
+
expect(insert?.callsOf("returning")).toEqual([["id"]])
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it("inserts no receipt for a delivery already claimed", async () => {
|
|
122
|
+
const subject = await build()
|
|
123
|
+
const manager = mockEntityManager({ query: [CLAIM_EVENT, []] })
|
|
124
|
+
|
|
125
|
+
await expect(subject.claimRow("webhook:spec", "delivery-1", manager, RECEIPT)).resolves.toBeUndefined()
|
|
126
|
+
|
|
127
|
+
expect(manager.createQueryBuilder).not.toHaveBeenCalled()
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it("answers undefined when the receipt row already exists", async () => {
|
|
131
|
+
const subject = await build()
|
|
132
|
+
const manager = mockEntityManager({ query: [CLAIM_EVENT, [{ event_id: "delivery-1" }]] })
|
|
133
|
+
queueQueryBuilders(manager, [{ execute: { raw: [] } }])
|
|
134
|
+
|
|
135
|
+
await expect(subject.claimRow("webhook:spec", "delivery-1", manager, RECEIPT)).resolves.toBeUndefined()
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it("refuses a returned receipt row without a text id", async () => {
|
|
139
|
+
const subject = await build()
|
|
140
|
+
const manager = mockEntityManager({ query: [CLAIM_EVENT, [{ event_id: "delivery-1" }]] })
|
|
141
|
+
queueQueryBuilders(manager, [{ execute: { raw: [{ id: 7 }] } }])
|
|
142
|
+
|
|
143
|
+
await expect(subject.claimRow("webhook:spec", "delivery-1", manager, RECEIPT)).rejects.toMatchObject({
|
|
144
|
+
code: DatabaseErrorCode.ReturningRowRejected,
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it("refuses a missing delivery id before claiming or inserting", async () => {
|
|
149
|
+
const subject = await build()
|
|
150
|
+
const manager = mockEntityManager()
|
|
151
|
+
|
|
152
|
+
await expect(subject.claimRow("webhook:spec", undefined, manager, RECEIPT)).rejects.toMatchObject({
|
|
153
|
+
code: DatabaseErrorCode.WebhookDeliveryIdRequired,
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
expect(manager.query).not.toHaveBeenCalled()
|
|
157
|
+
expect(manager.createQueryBuilder).not.toHaveBeenCalled()
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
|
|
97
161
|
describe("PostgresInbox client transaction admission", () => {
|
|
98
162
|
it("refuses a manager outside an active transaction before claiming or applying an effect", async () => {
|
|
99
163
|
const inbox = await build()
|