@starci/hfs 4.11.0 → 4.11.8
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 +63 -0
- package/README.md +40 -1
- package/db/drift.mjs +8 -6
- package/db/image.mjs +9 -4
- package/db/migrate.mjs +3 -3
- package/package.json +2 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +13 -8
- package/runtime/knowledge/hfs/rules.yaml +29 -12
- package/runtime/knowledge/hfs/slots.yaml +48 -37
- package/runtime/knowledge/patterns/be/event-bus.yaml +12 -10
- package/runtime/knowledge/patterns/be/queues.yaml +2 -3
- package/runtime/knowledge/patterns/be/reactors.yaml +1 -1
- package/runtime/modules/kernel/failure-codes.yaml +17 -17
- package/runtime/modules/schemas/application-stacks.schema.yaml +4 -2
- package/runtime/scripts/gates/starcistacks.mjs +6 -2
- package/runtime/scripts/guards/push-scan.mjs +1 -1
- package/runtime/scripts/hfs/ci-declaration.mjs +3 -2
- package/runtime/scripts/hfs/db-baseline-verb.mjs +50 -10
- package/runtime/scripts/hfs/db-errors.mjs +2 -0
- package/runtime/scripts/hfs/db-plan-verb.mjs +17 -5
- package/runtime/scripts/hfs/db-squash.mjs +514 -0
- package/runtime/scripts/hfs/declaration-shape.mjs +3 -15
- package/runtime/scripts/hfs/example-secrets.mjs +4 -3
- package/runtime/scripts/hfs/rule-params-shape.mjs +11 -9
- package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -1
- package/runtime/scripts/hfs/rules/database-config.mjs +1 -1
- package/runtime/scripts/hfs/rules/database-constants.mjs +16 -1
- package/runtime/scripts/hfs/rules/database-queue-sources.mjs +2 -110
- package/runtime/scripts/hfs/rules/database-queues.mjs +53 -239
- package/runtime/scripts/hfs/rules/database-sql.mjs +2 -0
- package/runtime/scripts/hfs/rules/database-transaction.mjs +45 -0
- package/runtime/scripts/hfs/rules/deploy-scan.mjs +1 -1
- package/runtime/scripts/hfs/rules/edition.mjs +1 -1
- package/runtime/scripts/hfs/rules/env-parity.mjs +1 -1
- package/runtime/scripts/hfs/rules/lite-secrets.mjs +3 -3
- package/runtime/scripts/hfs/rules/script-hygiene.mjs +1 -1
- package/runtime/scripts/hfs/rules/secret-custody-only.mjs +2 -2
- package/runtime/scripts/hfs/rules/secrets.mjs +1 -1
- package/runtime/scripts/hfs/rules/stack-tooling.mjs +1 -1
- package/runtime/scripts/hfs/rules/stacks-deploy.mjs +2 -1
- package/runtime/scripts/hfs/rules/stacks-lite-backing.mjs +2 -2
- package/runtime/scripts/hfs/rules/stacks-runtime.mjs +2 -2
- package/runtime/scripts/hfs/rules/stacks-swarm.mjs +1 -1
- package/runtime/scripts/hfs/rules/stacks.mjs +3 -3
- package/runtime/scripts/hfs/sealed-members.mjs +10 -6
- package/runtime/scripts/hfs/secret-files.mjs +16 -0
- package/runtime/scripts/hfs/secret-init.mjs +120 -21
- package/runtime/scripts/hfs/secret-output.mjs +2 -1
- package/runtime/scripts/hfs/secret-prompt.mjs +128 -0
- package/runtime/scripts/hfs/secret-sops.mjs +93 -9
- package/runtime/scripts/hfs/secret-standard.mjs +315 -0
- package/runtime/scripts/hfs/secret-store.mjs +23 -9
- package/runtime/scripts/hfs/secret.mjs +110 -46
- package/runtime/scripts/hfs/slots.mjs +8 -1
- package/runtime/scripts/hfs/stack-deploy-config.mjs +20 -6
- package/runtime/scripts/hfs/stacks-gate.mjs +9 -2
- package/runtime/scripts/hfs/starcistacks-runtime.mjs +1 -1
- package/runtime/scripts/lib/sealed-member.mjs +36 -0
- package/runtime/scripts/lib/secret-patterns.mjs +1 -1
- package/runtime/scripts/lib/swarm-stack.mjs +2 -2
- package/scaffold/add-inbox-lite.mjs +2 -2
- package/scaffold/app.mjs +2 -2
- package/src/main.mjs +5 -4
- package/stack/build.mjs +1 -1
- package/stack/config.mjs +2 -1
- package/stack/docker-transport.mjs +2 -1
- package/stack/remote.mjs +4 -3
- package/stack/sealed.mjs +9 -8
- package/stack/target-binding.mjs +2 -1
- package/sync/hygiene.mjs +1 -1
- package/sync/index.mjs +33 -10
- package/templates/app/db-bootstrap/db/bootstrap.sql +7 -4
- package/templates/app/db-image/starcistacks/images/db/Dockerfile +18 -0
- package/templates/app/gitignore +2 -1
- package/templates/app/gitignore-lite +3 -1
- package/templates/app/package-scripts/db.json +1 -1
- package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +1 -1
- package/templates/app/skeleton-lite/.starcistacks/dev/README.md +1 -1
- package/templates/app/skeleton-lite/.starcistacks/production/README.md +1 -1
- package/templates/app/skeleton-lite/README.md +2 -2
- package/templates/be/inbox/delivery-recorded.event.ts.tpl +7 -5
- package/templates/be/inbox/events.json.tpl +1 -1
- package/templates/be/inbox/platform/event-bus/event-bus.contracts.ts.tpl +58 -11
- package/templates/be/inbox/platform/event-bus/event-bus.decorators.ts.tpl +13 -1
- package/templates/be/inbox/platform/event-bus/event-bus.log-events.ts.tpl +9 -0
- package/templates/be/inbox/platform/event-bus/event-bus.module-definition.ts.tpl +1 -1
- package/templates/be/inbox/platform/event-bus/event-bus.module.ts.tpl +11 -5
- package/templates/be/inbox/platform/event-bus/event-bus.options.ts.tpl +9 -2
- package/templates/be/inbox/platform/event-bus/event-bus.port.ts.tpl +18 -5
- package/templates/be/inbox/platform/event-bus/event-bus.service.spec.ts.tpl +37 -16
- package/templates/be/inbox/platform/event-bus/event-bus.service.ts.tpl +33 -5
- package/templates/be/inbox/platform/event-bus/event-consumer.service.spec.ts.tpl +143 -0
- package/templates/be/inbox/platform/event-bus/event-consumer.service.ts.tpl +119 -0
- package/templates/be/inbox/platform/event-bus/index.ts.tpl +13 -4
- package/templates/be/inbox/platform/inbox/inbox.service.spec.ts.tpl +4 -4
- package/templates/be/inbox/platform/inbox/inbox.service.ts.tpl +4 -4
- package/templates/be/inbox/platform/migration.sql.tpl +97 -34
- package/templates/be/inbox/service.spec.ts.tpl +1 -1
- package/templates/be/inbox/webhook-door.ts.tpl +2 -2
- package/templates/be/patterns/jobs/platform-lite/job-claim.service.spec.ts.tpl +4 -4
- package/templates/be/patterns/jobs/platform-lite/job-claim.service.ts.tpl +5 -5
- package/templates/be/patterns/jobs/platform-lite/job-tick.service.spec.ts.tpl +1 -1
- package/templates/be/patterns/jobs/platform-lite/job-tick.service.ts.tpl +1 -1
- package/templates/be/patterns/queues/platform-lite/queue-outbox.service.spec.ts.tpl +1 -1
- package/templates/be/patterns/queues/platform-lite/queue-outbox.service.ts.tpl +1 -1
- package/templates/be/patterns/queues/platform-lite/queue-relay.service.spec.ts.tpl +2 -2
- package/templates/be/patterns/queues/platform-lite/queue-relay.service.ts.tpl +2 -2
- package/templates/be/skeleton/apps/__app__/src/__app__.env.ts +4 -0
- package/templates/be/skeleton/apps/__app__/src/__app__.options.ts +3 -0
- package/templates/be/skeleton/apps/__app__/src/compose/platform.ts +1 -1
- package/templates/be/skeleton/src/modules/platform/http/fetch-http-client.client.spec.ts +37 -2
- package/templates/be/skeleton/src/modules/platform/http/fetch-http-client.client.ts +36 -3
- package/templates/be/skeleton/src/modules/platform/http/http.config.ts +11 -0
- package/templates/be/skeleton/src/modules/platform/http/http.decorators.ts +7 -0
- package/templates/be/skeleton/src/modules/platform/http/http.module-definition.ts +5 -3
- package/templates/be/skeleton/src/modules/platform/http/http.options.ts +9 -2
- package/templates/be/skeleton/src/modules/platform/http/index.ts +2 -0
- package/templates/be/skeleton-lite/src/modules/platform/database/database.client.spec.ts +96 -0
- package/templates/be/skeleton-lite/src/modules/platform/database/database.client.ts +93 -0
- package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +1 -1
- package/runtime/scripts/hfs/rules/database-queue-outbox.mjs +0 -102
- package/templates/app/db-image/db/Dockerfile +0 -8
- package/templates/be/skeleton-lite/src/modules/platform/database/database.functions.ts +0 -24
package/stack/remote.mjs
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// `bash -s` fail with `set: pipefail`). Key material lives in memory and in a private temp directory only; it is never in argv (only the key
|
|
9
9
|
// FILE path is), in a line, in a result or in an error message.
|
|
10
10
|
import fs from 'node:fs';
|
|
11
|
+
import { isLegacyName, memberFile } from '../runtime/scripts/lib/sealed-member.mjs';
|
|
11
12
|
import os from 'node:os';
|
|
12
13
|
import path from 'node:path';
|
|
13
14
|
import { tool } from './context.mjs';
|
|
@@ -78,7 +79,7 @@ function openMember(slug, read) {
|
|
|
78
79
|
|
|
79
80
|
function opsAccount(ctx, config) {
|
|
80
81
|
const slug = config.remote.sealed.ops;
|
|
81
|
-
const map = openMember(slug, () => ctx.sops.decrypt(
|
|
82
|
+
const map = openMember(slug, () => ctx.sops.decrypt(memberFile(config.secretsDir, slug), isLegacyName(memberFile(config.secretsDir, slug)) ? 'dotenv' : 'yaml'));
|
|
82
83
|
for (const name of ['VPS_HOST', 'VPS_USER']) {
|
|
83
84
|
const value = map[name];
|
|
84
85
|
if (typeof value !== 'string' || !ACCOUNT.test(value) || value.startsWith('-')) throw refusal('STACK_REMOTE_OPS', `${slug} must hold ${name} (letters, digits, . _ @ -)`);
|
|
@@ -87,7 +88,7 @@ function opsAccount(ctx, config) {
|
|
|
87
88
|
}
|
|
88
89
|
|
|
89
90
|
function binaryMember(ctx, config, slug, names) {
|
|
90
|
-
return unwrap(openMember(slug, () => ctx.sops.decryptBinary(
|
|
91
|
+
return unwrap(openMember(slug, () => ctx.sops.decryptBinary(memberFile(config.secretsDir, slug))), names);
|
|
91
92
|
}
|
|
92
93
|
|
|
93
94
|
const withNewline = (text) => `${text.trim()}\n`;
|
|
@@ -159,7 +160,7 @@ function withOwnerTransport(ctx, config, { sshCmd = null } = {}) {
|
|
|
159
160
|
const { sealed } = config.remote;
|
|
160
161
|
if (config.nativeTarget) return Object.create(ctx, { env: { value: { ...ctx.env, STARCI_STACK_SEALED_SSH: '1' }, enumerable: true } });
|
|
161
162
|
if (sshCmd || !sealed || ctx.env.GITHUB_ACTIONS === 'true' || ctx.env.STARCI_STACK_SEALED_SSH !== undefined) return ctx;
|
|
162
|
-
const present = [sealed.ops, sealed.sshKey, sealed.knownHosts].every((slug) => fs.existsSync(
|
|
163
|
+
const present = [sealed.ops, sealed.sshKey, sealed.knownHosts].every((slug) => fs.existsSync(memberFile(config.secretsDir, slug)));
|
|
163
164
|
if (!present) return ctx;
|
|
164
165
|
return Object.create(ctx, { env: { value: { ...ctx.env, STARCI_STACK_SEALED_SSH: '1' }, enumerable: true } });
|
|
165
166
|
}
|
package/stack/sealed.mjs
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// service is granted (the shared half of the release tools). No value is ever written to disk, printed or put in an argument list:
|
|
3
3
|
// callers receive `value` and must keep it in memory. Config objects (non-secret files mounted into services) are versioned by content.
|
|
4
4
|
import { createHash, createHmac } from 'node:crypto';
|
|
5
|
+
import { memberFile } from '../runtime/scripts/lib/sealed-member.mjs';
|
|
5
6
|
import fs from 'node:fs';
|
|
6
7
|
import path from 'node:path';
|
|
7
8
|
import { secretRoots } from '../runtime/scripts/hfs/secret-files.mjs';
|
|
@@ -65,10 +66,10 @@ export function requireAgeIdentity(ctx, config, purpose) {
|
|
|
65
66
|
if (!hasAgeIdentity(ctx, config)) throw refusal('STACK_AGE_IDENTITY_MISSING', `${purpose} needs the sealed members, and no age identity is available: ${AGE_HINT}`);
|
|
66
67
|
}
|
|
67
68
|
|
|
68
|
-
/** Opens one sealed member (`<secretsDir>/<member>.enc`) into a { KEY: value } map. Names the member only on failure. */
|
|
69
|
+
/** Opens one sealed member (`<secretsDir>/<member>.enc.yaml`, or a legacy `.enc`) into a { KEY: value } map. Names the member only on failure. */
|
|
69
70
|
export function openMember(ctx, config, member) {
|
|
70
71
|
try {
|
|
71
|
-
const map = readMap(
|
|
72
|
+
const map = readMap(memberFile(config.secretsDir, member), ctx.sops);
|
|
72
73
|
return Object.fromEntries(Object.entries(map).map(([key, value]) => [key, typeof value === 'string' && !value.includes(String.fromCodePoint(0)) ? lfText(value) : value]));
|
|
73
74
|
} catch {
|
|
74
75
|
throw refusal('STACK_SECRET_MEMBER_UNREADABLE', `the sealed member ${member} cannot be opened${hasAgeIdentity(ctx, config) ? ' (is the age identity a recipient of this member?)' : `: no age identity is available, ${AGE_HINT}`}`);
|
|
@@ -116,13 +117,13 @@ function sealGrants(config, memberOf, versionKey) {
|
|
|
116
117
|
}
|
|
117
118
|
|
|
118
119
|
function sealSettings(config, memberOf) {
|
|
119
|
-
const block = config.secrets.settings;
|
|
120
|
-
if (block === null) return {};
|
|
121
|
-
const map = memberOf(block.member);
|
|
122
120
|
const settings = {};
|
|
123
|
-
for (const
|
|
124
|
-
|
|
125
|
-
|
|
121
|
+
for (const block of config.secrets.settings) {
|
|
122
|
+
const map = memberOf(block.member);
|
|
123
|
+
for (const key of block.keys.filter((name) => present(map, name))) {
|
|
124
|
+
if (UNSAFE_SETTING.test(map[key])) throw refusal('STACK_SETTING_INVALID', `${key} must be a plain one-line value`);
|
|
125
|
+
settings[key] = map[key];
|
|
126
|
+
}
|
|
126
127
|
}
|
|
127
128
|
return settings;
|
|
128
129
|
}
|
package/stack/target-binding.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Public endpoint assertions for admitted native consumers; this module never opens custody.
|
|
2
2
|
import fs from 'node:fs';
|
|
3
|
+
import { memberFile } from '../runtime/scripts/lib/sealed-member.mjs';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import { refusal } from './errors.mjs';
|
|
5
6
|
|
|
@@ -52,7 +53,7 @@ export function bindStackTarget(ctx, config, binding) {
|
|
|
52
53
|
|| !config.remote.sealed || ctx.env.DOCKER_HOST || ctx.env.DOCKER_CONTEXT || ctx.env.GITHUB_ACTIONS
|
|
53
54
|
|| ctx.env.STARCI_STACK_SEALED_SSH !== undefined) denied();
|
|
54
55
|
for (const member of Object.values(config.remote.sealed)) {
|
|
55
|
-
const file =
|
|
56
|
+
const file = memberFile(config.secretsDir, member);
|
|
56
57
|
assertBoundCustody(config.repoRoot, file);
|
|
57
58
|
}
|
|
58
59
|
return { ...config, nativeTarget: binding };
|
package/sync/hygiene.mjs
CHANGED
|
@@ -58,7 +58,7 @@ export function hygieneFindings(files, ignored) {
|
|
|
58
58
|
if (file.startsWith(WORK) && ignored.has(file)) {
|
|
59
59
|
findings.push({ file, code: 'HFS_WORK_AGENT_DATA', message: 'is agent output, which .starciwork/.gitignore refuses; keep it in the scratchpad or the blob store' });
|
|
60
60
|
}
|
|
61
|
-
if (file.startsWith(STACKS) &&
|
|
61
|
+
if (file.startsWith(STACKS) && !/\.enc(?:\.yaml)?$/.test(file) && !file.endsWith('.env.example') && !file.endsWith('/secrets/.gitkeep')) {
|
|
62
62
|
if (file.includes('/secrets/')) findings.push({ file, code: 'HFS_PLAINTEXT_SECRET', message: 'sits under secrets/ but is not sealed; only <slug>.enc may be tracked' });
|
|
63
63
|
else if (PLAINTEXT_NAME.test(file)) findings.push({ file, code: 'HFS_PLAINTEXT_SECRET', message: 'is a plaintext secret; seal it to .starcistacks/<env>/secrets/<slug>.enc' });
|
|
64
64
|
}
|
package/sync/index.mjs
CHANGED
|
@@ -30,7 +30,7 @@ import { DEFAULT_PORT, NODE_IMAGE, dockerfilePath } from '../runtime/scripts/hfs
|
|
|
30
30
|
import { isFile } from '../runtime/scripts/lib/fs-kind.mjs';
|
|
31
31
|
import { readDeclaredSonarKey } from './sonar-key.mjs';
|
|
32
32
|
import { readDeclaredStack, stackWorkflowVariables } from './stack-images.mjs';
|
|
33
|
-
import { DB_DOCKERFILE, DB_IMAGE_APP } from '../db/image.mjs';
|
|
33
|
+
import { DB_DOCKERFILE, DB_IMAGE_APP, RETIRED_DB_DOCKERFILE } from '../db/image.mjs';
|
|
34
34
|
import { byCodeUnit } from '../report/order.mjs';
|
|
35
35
|
import { isRuntimeExampleApp } from '../runtime/scripts/lib/runtime-example.mjs';
|
|
36
36
|
import { ciWorkflowCommands } from './ci-commands.mjs';
|
|
@@ -40,6 +40,7 @@ import { grammarLintFor } from '../runtime/scripts/lib/grammar-lint.mjs';
|
|
|
40
40
|
import { LIST } from '../runtime/scripts/lib/loosening-config.mjs';
|
|
41
41
|
import { readTruths } from '../runtime/scripts/lib/loosening-state.mjs';
|
|
42
42
|
import { parseYaml as bundledParseYaml } from '../runtime/engine/yaml.mjs';
|
|
43
|
+
import { bootstrapSection } from '../runtime/scripts/hfs/rules/database-constants.mjs';
|
|
43
44
|
|
|
44
45
|
export const TEMPLATES_DIR = path.join(import.meta.dirname, '..', 'templates');
|
|
45
46
|
const NODE_MAJOR = 22;
|
|
@@ -58,9 +59,10 @@ export class SyncError extends Error {
|
|
|
58
59
|
}
|
|
59
60
|
|
|
60
61
|
// mode: file = whole file, block = the marked block inside a file the app also writes to, scripts = the `scripts` block of
|
|
61
|
-
// package.json
|
|
62
|
-
//
|
|
63
|
-
|
|
62
|
+
// package.json, section = the whole file but the one marked section a database verb owns (db/bootstrap.sql, whose squash section
|
|
63
|
+
// `starci app db baseline --squash` writes). A template that can hold a comment opens with {{header}}; a file whose exact content is
|
|
64
|
+
// specified (JSON, the eslint one-liner) has no header.
|
|
65
|
+
const MODE_OF_GROUP = Object.freeze({ 'package-scripts': 'scripts', 'package-scripts-lite': 'scripts', 'db-bootstrap': 'section' });
|
|
64
66
|
const GLOB_CHARACTERS = new Set('*?<>{}[]');
|
|
65
67
|
const literalFile = file => file.length >= 2 && !file.endsWith('/') && ![...file].some(character => GLOB_CHARACTERS.has(character));
|
|
66
68
|
|
|
@@ -158,13 +160,22 @@ function trailingNewline(text) {
|
|
|
158
160
|
}
|
|
159
161
|
|
|
160
162
|
/**
|
|
161
|
-
* The base image of a lite app's managed db/Dockerfile (contexts/stack/pins.yaml images postgres, ruling:lite-official-postgres-image): the
|
|
162
|
-
* Docker Official postgres image by digest, to which the Dockerfile adds
|
|
163
|
+
* The base image of a lite app's managed .starcistacks/images/db/Dockerfile (contexts/stack/pins.yaml images postgres, ruling:lite-official-postgres-image): the
|
|
164
|
+
* Docker Official postgres image by digest, to which the Dockerfile adds PGVECTOR_PACKAGE from PGDG and the PGMQ release.
|
|
163
165
|
*/
|
|
164
166
|
export const DB_BASE_IMAGE = 'postgres:17.11@sha256:31ba9c94a03ada9988f2bb493a22a75eef3a185884533e5cdbcd498ea835b08f';
|
|
165
167
|
/** The PGDG package of pgvector at the exact version the db image installs (the one extension the image does not ship). */
|
|
166
168
|
export const PGVECTOR_PACKAGE = 'postgresql-17-pgvector=0.8.7-1.pgdg13+1';
|
|
167
|
-
/**
|
|
169
|
+
/**
|
|
170
|
+
* The pgmq release the db image installs for the event bus (ruling:lite-pgmq-bus-bullmq-jobs): its source tarball, verified by sha256
|
|
171
|
+
* when Docker adds it. PGDG does not package pgmq; it is SQL objects only, so the Dockerfile copies its control and script files.
|
|
172
|
+
*/
|
|
173
|
+
export const PGMQ = Object.freeze({
|
|
174
|
+
version: '1.13.1',
|
|
175
|
+
source: 'https://github.com/pgmq/pgmq/archive/refs/tags/v1.13.1.tar.gz',
|
|
176
|
+
sha256: '67221f288f42f95c002a64df40f7d660e765e32df8ada0f2672ba915a14827f7',
|
|
177
|
+
});
|
|
178
|
+
/** The local tag the development db container and the drift scratch database run, built from the app's .starcistacks/images/db/Dockerfile. */
|
|
168
179
|
export const dbLocalImage = project => `${project}-db:local`;
|
|
169
180
|
/** The digest-pinned image of a lite app's declared Redis, in every environment that runs it (ruling:R212/case-10). */
|
|
170
181
|
export const REDIS_IMAGE = 'redis:7@sha256:c6eabf748fc7a61dbb5a705c78bcf3d6377b1127a97d0ce965c11c44ba46896f';
|
|
@@ -385,10 +396,13 @@ export function variables(app, scope, presets, sonarKey, manifest = loadSlotMani
|
|
|
385
396
|
project: app.project,
|
|
386
397
|
appBinary: APP_BINARY,
|
|
387
398
|
appScripts: appScripts(app, source),
|
|
388
|
-
// A lite app's own database runs in development as one container of the image its db/Dockerfile builds (ruling:lite-
|
|
399
|
+
// A lite app's own database runs in development as one container of the image its .starcistacks/images/db/Dockerfile builds (ruling:lite-db-image-in-stacks).
|
|
389
400
|
dbImage: dbLocalImage(app.project),
|
|
390
401
|
dbBaseImage: DB_BASE_IMAGE,
|
|
391
402
|
pgvectorPackage: PGVECTOR_PACKAGE,
|
|
403
|
+
pgmqVersion: PGMQ.version,
|
|
404
|
+
pgmqSource: PGMQ.source,
|
|
405
|
+
pgmqSha256: PGMQ.sha256,
|
|
392
406
|
// A lite app's declared Redis (sides.be.backing, ruling:R212/case-10) runs in development as one container of its pinned image.
|
|
393
407
|
backingScripts: lite && (app.sides.be.backing ?? []).includes('redis') ? redisDevText(app.project) : '',
|
|
394
408
|
// turbo builds every fe app workspace; ^build (turbo.json) builds the packages each app imports first, and caches both.
|
|
@@ -472,9 +486,10 @@ export function renderTargets(hfs, presets, { sonarKey, readTemplate = readBundl
|
|
|
472
486
|
});
|
|
473
487
|
}
|
|
474
488
|
|
|
475
|
-
// The part of a file sync manages: all of it, the marked block, or the scripts block as text.
|
|
489
|
+
// The part of a file sync manages: all of it, all of it but the verb's section, the marked block, or the scripts block as text.
|
|
476
490
|
function managedPart(mode, text, markers = { begin: BLOCK_BEGIN, end: BLOCK_END }) {
|
|
477
491
|
if (mode === 'file') return text;
|
|
492
|
+
if (mode === 'section') return bootstrapSection(text).head;
|
|
478
493
|
if (mode === 'scripts') {
|
|
479
494
|
const scripts = parseScripts(text);
|
|
480
495
|
return scripts ? scriptsText(scripts) : null;
|
|
@@ -524,6 +539,7 @@ export function writtenContent(target, existing) {
|
|
|
524
539
|
}
|
|
525
540
|
return `${JSON.stringify({ ...pkg, scripts: target.scripts }, null, 2)}\n`;
|
|
526
541
|
}
|
|
542
|
+
if (target.mode === 'section') return `${target.content}${bootstrapSection(existing ?? '').section ?? ''}`;
|
|
527
543
|
if (target.mode === 'block' && existing) {
|
|
528
544
|
const current = managedPart('block', existing, target.markers);
|
|
529
545
|
if (current !== null) return existing.replace(current, () => target.content);
|
|
@@ -571,6 +587,12 @@ export async function renderRepo(root, { presets, parseYaml, notices, manifest =
|
|
|
571
587
|
|
|
572
588
|
/** The folders a lite app's sync retires (ruling lite-self-hosted-postgres-passport-minio): the generated Supabase row types nothing reads any more. */
|
|
573
589
|
const LITE_RETIRED_DIRS = Object.freeze(['supabase/types']);
|
|
590
|
+
/** The files a lite app's sync retires, each with the reason --check prints. */
|
|
591
|
+
const LITE_RETIRED_FILES = Object.freeze({
|
|
592
|
+
[RETIRED_DB_DOCKERFILE]: `the db image moved to ${DB_DOCKERFILE} (ruling lite-db-image-in-stacks)`,
|
|
593
|
+
});
|
|
594
|
+
/** Why a retired file goes: a retired file's own reason, else the folder's. */
|
|
595
|
+
const retiredReason = (file) => LITE_RETIRED_FILES[file] ?? 'the lite edition generates no database types';
|
|
574
596
|
|
|
575
597
|
/** The app-relative files of the retired folders that still exist under `root` of a lite app; sync --write deletes them, --check reports them. */
|
|
576
598
|
export function retiredFiles(root, hfs) {
|
|
@@ -584,6 +606,7 @@ export function retiredFiles(root, hfs) {
|
|
|
584
606
|
else found.push(rel);
|
|
585
607
|
};
|
|
586
608
|
for (const dir of LITE_RETIRED_DIRS) walk(dir);
|
|
609
|
+
for (const file of Object.keys(LITE_RETIRED_FILES)) walk(file);
|
|
587
610
|
return found;
|
|
588
611
|
}
|
|
589
612
|
|
|
@@ -602,7 +625,7 @@ function syncOptions(argv) {
|
|
|
602
625
|
function reportSyncCheck(root, targets, out, retired = []) {
|
|
603
626
|
const results = checkTargets(root, targets);
|
|
604
627
|
const bad = results.filter(item => item.status !== 'ok');
|
|
605
|
-
for (const file of retired) out(`HFS_SYNC_DRIFT ${file}: retired,
|
|
628
|
+
for (const file of retired) out(`HFS_SYNC_DRIFT ${file}: retired, ${retiredReason(file)}; run "npx starci app sync --write" to delete it`);
|
|
606
629
|
for (const result of bad) {
|
|
607
630
|
const found = result.actualHash ? `, found ${result.actualHash.slice(0, 12)}` : '';
|
|
608
631
|
const where = result.difference ? ` (line ${result.difference.line}: expected ${JSON.stringify(result.difference.expected)}, found ${JSON.stringify(result.difference.actual)})` : '';
|
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
-- Managed by starci app sync (slot app.db.bootstrap); do not edit: run "npx starci app sync --write".
|
|
2
2
|
-- The pre-migration bootstrap of a lite app's self-hosted PostgreSQL (ruling lite-self-hosted-postgres-passport-minio), run on the official
|
|
3
|
-
-- postgres image of db/Dockerfile (ruling lite-official-postgres-image). Migrations written for Supabase reference objects GoTrue and Supabase
|
|
3
|
+
-- postgres image of .starcistacks/images/db/Dockerfile (ruling lite-official-postgres-image). Migrations written for Supabase reference objects GoTrue and Supabase
|
|
4
4
|
-- Storage created: the browser roles, the `auth` schema (auth.users, auth.identities, auth.sessions, auth.uid/jwt/role) and the `storage`
|
|
5
5
|
-- schema (storage.buckets, storage.objects). A lite app runs neither GoTrue nor storage-api: auth is the app's own (integrations/passport) and
|
|
6
|
-
-- storage is the S3 endpoint of integrations/object-storage. This file gives the migrations those objects, EMPTY, and
|
|
7
|
-
--
|
|
8
|
-
--
|
|
6
|
+
-- storage is the S3 endpoint of integrations/object-storage. This file gives the migrations those objects, EMPTY, and the `extensions` schema
|
|
7
|
+
-- every allowed extension is created in (`create extension ... with schema extensions`, the app role's search_path is `public, extensions`);
|
|
8
|
+
-- nothing else of Supabase (supabase_admin, the realtime publication) exists. It is idempotent; `starci app db migrate` applies it as the
|
|
9
|
+
-- database owner before the first migration of an empty database.
|
|
9
10
|
--
|
|
10
11
|
-- auth.users keeps the GoTrue column set so restored rows and foreign keys from public tables stay valid; encrypted_password holds bcrypt hashes.
|
|
11
12
|
-- auth.uid(), auth.role() and auth.jwt() read the request.jwt.claims setting; the API connects as app_be and sets none, so they return NULL and
|
|
12
13
|
-- every policy that names them denies the browser roles, which have no door in lite.
|
|
13
14
|
|
|
15
|
+
create schema if not exists extensions;
|
|
14
16
|
create schema if not exists auth;
|
|
15
17
|
create schema if not exists storage;
|
|
16
18
|
|
|
@@ -23,6 +25,7 @@ end
|
|
|
23
25
|
$roles$;
|
|
24
26
|
|
|
25
27
|
grant usage on schema auth, storage to anon, authenticated, service_role;
|
|
28
|
+
grant usage on schema extensions to anon, authenticated, service_role;
|
|
26
29
|
|
|
27
30
|
-- auth.users: the full GoTrue column set (a copy an earlier database carries is extended in place).
|
|
28
31
|
create table if not exists auth.users (id uuid primary key);
|
|
@@ -0,0 +1,18 @@
|
|
|
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, kept with the stacks (rulings lite-official-postgres-image, lite-pgmq-bus-bullmq-jobs and
|
|
3
|
+
# lite-db-image-in-stacks): the Docker Official postgres image, pinned by digest, plus pgvector from the PGDG repository the image
|
|
4
|
+
# already configures and pgmq, the event bus, from its pinned release source verified by sha256 (pgmq is SQL objects only, so its
|
|
5
|
+
# control and script files are copied and nothing is compiled; it installs into its own schema pgmq). The contrib extensions
|
|
6
|
+
# (pgcrypto, pg_trgm, unaccent, uuid-ossp, btree_gist) ship in the image. The stack's db service, `npm run db:create`,
|
|
7
|
+
# `starci app db drift` and `starci app db baseline --squash` all build this file, with this folder as the build context.
|
|
8
|
+
FROM {{dbBaseImage}}
|
|
9
|
+
ADD --checksum=sha256:{{pgmqSha256}} {{pgmqSource}} /tmp/pgmq.tar.gz
|
|
10
|
+
RUN apt-get update \
|
|
11
|
+
&& apt-get install -y --no-install-recommends {{pgvectorPackage}} \
|
|
12
|
+
&& rm -rf /var/lib/apt/lists/* \
|
|
13
|
+
&& tar -xzf /tmp/pgmq.tar.gz -C /tmp \
|
|
14
|
+
&& extension="$(pg_config --sharedir)/extension" \
|
|
15
|
+
&& cp /tmp/pgmq-{{pgmqVersion}}/pgmq-extension/pgmq.control "$extension/" \
|
|
16
|
+
&& cp /tmp/pgmq-{{pgmqVersion}}/pgmq-extension/sql/pgmq.sql "$extension/pgmq--{{pgmqVersion}}.sql" \
|
|
17
|
+
&& cp /tmp/pgmq-{{pgmqVersion}}/pgmq-extension/sql/pgmq--*--*.sql "$extension/" \
|
|
18
|
+
&& rm -rf /tmp/pgmq.tar.gz /tmp/pgmq-{{pgmqVersion}}
|
package/templates/app/gitignore
CHANGED
|
@@ -7,7 +7,7 @@ schema.gql
|
|
|
7
7
|
.next/
|
|
8
8
|
next-env.d.ts
|
|
9
9
|
# .starcistacks custody at the app root: modules/schemas/stacks-layout.yaml custody.gitignoreRules, in order. Only the
|
|
10
|
-
# declaration, runbooks, key lists, seeds, the rendered environment, the infra definitions and sealed *.enc are tracked.
|
|
10
|
+
# declaration, runbooks, key lists, seeds, the rendered environment, the infra definitions and sealed members (*.enc.yaml, legacy *.enc) are tracked.
|
|
11
11
|
.starcistacks/**
|
|
12
12
|
!.starcistacks/**/
|
|
13
13
|
!.starcistacks/**/KEYS.md
|
|
@@ -32,6 +32,7 @@ next-env.d.ts
|
|
|
32
32
|
.starcistacks/*/infra/terraform/*.tfstate.*
|
|
33
33
|
.starcistacks/*/infra/terraform/files/
|
|
34
34
|
.starcistacks/*/infra/terraform/*.tar.gz
|
|
35
|
+
!.starcistacks/**/secrets/*.enc.yaml
|
|
35
36
|
!.starcistacks/**/*.enc
|
|
36
37
|
|
|
37
38
|
# Host-local agent credentials and app age identity never enter Git.
|
|
@@ -7,12 +7,13 @@ schema.gql
|
|
|
7
7
|
.next/
|
|
8
8
|
next-env.d.ts
|
|
9
9
|
# .starcistacks custody at the app root: modules/schemas/stacks-layout.yaml custody.gitignoreRules, in order. Only the
|
|
10
|
-
# declaration, runbooks, key lists, seeds, the rendered environment, the infra definitions and sealed *.enc are tracked.
|
|
10
|
+
# declaration, runbooks, key lists, seeds, the rendered environment, the infra definitions, the db image and sealed members (*.enc.yaml, legacy *.enc) are tracked.
|
|
11
11
|
.starcistacks/**
|
|
12
12
|
!.starcistacks/**/
|
|
13
13
|
!.starcistacks/**/KEYS.md
|
|
14
14
|
!.starcistacks/**/.gitkeep
|
|
15
15
|
!.starcistacks/application-stacks.yaml
|
|
16
|
+
!.starcistacks/images/db/Dockerfile
|
|
16
17
|
!.starcistacks/*/README.md
|
|
17
18
|
!.starcistacks/*/environment.json
|
|
18
19
|
!.starcistacks/*/seeds/**
|
|
@@ -32,6 +33,7 @@ next-env.d.ts
|
|
|
32
33
|
.starcistacks/*/infra/terraform/*.tfstate.*
|
|
33
34
|
.starcistacks/*/infra/terraform/files/
|
|
34
35
|
.starcistacks/*/infra/terraform/*.tar.gz
|
|
36
|
+
!.starcistacks/**/secrets/*.enc.yaml
|
|
35
37
|
!.starcistacks/**/*.enc
|
|
36
38
|
|
|
37
39
|
# Host-local agent credentials and app age identity never enter Git.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"db:image": "docker build --tag {{dbImage}} db",
|
|
1
|
+
"db:image": "docker build --tag {{dbImage}} .starcistacks/images/db",
|
|
2
2
|
"db:create": "npm run db:image && docker create --name {{project}}-dev-db -p 127.0.0.1:54322:5432 -e POSTGRES_HOST_AUTH_METHOD=trust -v {{project}}-dev-db-data:/var/lib/postgresql/data {{dbImage}}",
|
|
3
3
|
"db:start": "docker start {{project}}-dev-db",
|
|
4
4
|
"db:stop": "docker stop {{project}}-dev-db",
|
|
@@ -29,7 +29,7 @@ components:
|
|
|
29
29
|
volumes: [db-data]
|
|
30
30
|
image: "{{project}}/db:v0.0.0@sha256:0000000000000000000000000000000000000000000000000000000000000000"
|
|
31
31
|
compose: infra/swarm/stack.yaml
|
|
32
|
-
purpose: The app's own PostgreSQL, the official postgres image plus pgvector built from db/Dockerfile, migrated by starci app db migrate.
|
|
32
|
+
purpose: The app's own PostgreSQL, the official postgres image plus pgvector built from .starcistacks/images/db/Dockerfile, migrated by starci app db migrate.
|
|
33
33
|
required: true
|
|
34
34
|
ingress:
|
|
35
35
|
role: ingress
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Development stack
|
|
2
2
|
|
|
3
|
-
Every development dependency runs in Docker Linux containers. The `db` service (the official postgres image plus pgvector, built from
|
|
3
|
+
Every development dependency runs in Docker Linux containers. The `db` service (the official postgres image plus pgvector, built from `.starcistacks/images/db/Dockerfile`) starts with `npm run db:start` and takes the migrations with `npm run db:push`; storage is the S3-compatible MinIO the environment names. Every other declared dependency, such as Redis, starts through its managed `<dep>:create`, `<dep>:start` and `<dep>:stop` scripts or the declared container model; Compose is an implementation detail. The app services may run in the local Docker Swarm model or through a declared native development profile while dependencies stay in Docker.
|
|
4
4
|
The application image references contain illustrative all-zero digests. Replace them with verified published release digests in the manifest, model and `.starcistacks/dev/infra/swarm/releases/images.lock.yaml` before starting the container model. Seal the local database connection and the integration credentials; a container cannot use the host's loopback address without owner-configured networking. Local source development remains available through the app scripts.
|
|
5
5
|
|
|
6
6
|
The runtime template tracks no encrypted members. Scaffolding generates the six declared members with placeholder values encrypted to this app's own recipient in `.sops.yaml`. Replace those values with `starci app secret set dev` using the matching host age identity in untracked local custody. Existing generated envelopes are decryptable with that app identity and can be updated in place. Each Docker secret contains the corresponding key's real value, and the API receives it through its declared mounted file.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Production stack
|
|
2
2
|
|
|
3
3
|
The tracked model declares Docker Swarm application services and one digest-pinned Caddy ingress.
|
|
4
|
-
The `db` service (the official postgres image plus pgvector, built from
|
|
4
|
+
The `db` service (the official postgres image plus pgvector, built from `.starcistacks/images/db/Dockerfile` and pushed with the release images) supplies PostgreSQL on a named volume; sign-in is the API's own (Passport) and storage is the S3-compatible MinIO the environment names. The app and its dependencies run on Docker Swarm. A Redis service runs only when `hfs.json` `sides.be.backing` declares it for a capability that needs it: one stateful replica with a digest-pinned image, a named volume with a backup entry and its password as a sealed member granted as a Swarm secret (Redis runs `--appendonly yes` with the volume at `/data`). The scaffold declares none; `starci app add queue|job` declares Redis and renders it here.
|
|
5
5
|
The model is a deployment declaration. It contains no owner credentials, real DNS receipt, completed release or restore proof. Public records under `.starcistacks/production/infra/swarm/` explicitly mark those owner-run operations as pending.
|
|
6
6
|
Its application image references contain illustrative all-zero digests; they identify no published image and must be replaced before deployment.
|
|
7
7
|
|
|
@@ -8,7 +8,7 @@ The starter keeps one deployable API and one web app, with the same code structu
|
|
|
8
8
|
|
|
9
9
|
## Stack
|
|
10
10
|
|
|
11
|
-
TypeScript everywhere, NestJS in `be/`, Next.js with next-intl in `fe/`, PostgreSQL (the `db` service: the official postgres image plus pgvector, built from
|
|
11
|
+
TypeScript everywhere, NestJS in `be/`, Next.js with next-intl in `fe/`, PostgreSQL (the `db` service: the official postgres image plus pgvector, built from `.starcistacks/images/db/Dockerfile`), Passport for Google and password sign-in, S3-compatible storage (MinIO), npm workspaces, and Turbo for front-end tasks.
|
|
12
12
|
|
|
13
13
|
## Repository layout
|
|
14
14
|
|
|
@@ -24,7 +24,7 @@ Declare `ci.github.repository` and `ci.github.defaultBranch` in `.starcistacks/a
|
|
|
24
24
|
|
|
25
25
|
## Development
|
|
26
26
|
|
|
27
|
-
Every development dependency runs in Docker Linux containers. Create the local database once with `npm run db:create`, start it with `npm run db:start` (one container of the image
|
|
27
|
+
Every development dependency runs in Docker Linux containers. Create the local database once with `npm run db:create`, start it with `npm run db:start` (one container of the image `.starcistacks/images/db/Dockerfile` builds, on 127.0.0.1:54322), export `DATABASE_URL=postgresql://postgres@127.0.0.1:54322/postgres` and apply `db/bootstrap.sql` and the migrations with `npm run db:push`; start any other declared dependency through its managed `<dep>:create`, `<dep>:start` and `<dep>:stop` scripts or the declared container model. Compose is an implementation detail. Configure the local environment and start the API and web app using the package scripts for the declared development profile. In production, the app and its dependencies, the database included, run on Docker Swarm; pgvector owns vector search.
|
|
28
28
|
|
|
29
29
|
```sh
|
|
30
30
|
npm install
|
|
@@ -3,6 +3,8 @@ import type { ParsedEvent } from "@modules/platform/event-bus"
|
|
|
3
3
|
|
|
4
4
|
/** A durable receipt reference; processing reads the provider payload after the intake commits. */
|
|
5
5
|
export interface @@Provider@@DeliveryRecordedPayload {
|
|
6
|
+
/** The business key a consumer's inbox claims: the receipt, once per consumer however often it is delivered. */
|
|
7
|
+
readonly dedupeKey: string
|
|
6
8
|
readonly receiptId: string
|
|
7
9
|
}
|
|
8
10
|
|
|
@@ -20,17 +22,17 @@ export class @@Provider@@DeliveryRecordedEvent extends BaseEvent {
|
|
|
20
22
|
super()
|
|
21
23
|
}
|
|
22
24
|
|
|
23
|
-
/** The receipt identity is stable across
|
|
24
|
-
static create(payload:
|
|
25
|
-
return new @@Provider@@DeliveryRecordedEvent(payload.receiptId, payload)
|
|
25
|
+
/** The receipt identity is stable across redeliveries and distinct from a consumer's inbox source. */
|
|
26
|
+
static create(payload: Omit<@@Provider@@DeliveryRecordedPayload, "dedupeKey">): @@Provider@@DeliveryRecordedEvent {
|
|
27
|
+
return new @@Provider@@DeliveryRecordedEvent(payload.receiptId, { dedupeKey: payload.receiptId, receiptId: payload.receiptId })
|
|
26
28
|
}
|
|
27
29
|
|
|
28
|
-
/** Reads
|
|
30
|
+
/** Reads the event envelope; malformed deliveries never reach a processing consumer. */
|
|
29
31
|
static parse(envelope: unknown): ParsedEvent<@@Provider@@DeliveryRecordedEvent> {
|
|
30
32
|
if (typeof envelope !== "object" || envelope === null || !("eventId" in envelope) || !("payload" in envelope)) return null
|
|
31
33
|
const { eventId, payload } = envelope
|
|
32
34
|
if (typeof eventId !== "string" || !eventId.trim() || typeof payload !== "object" || payload === null || !("receiptId" in payload)) return null
|
|
33
35
|
if (typeof payload.receiptId !== "string" || !payload.receiptId.trim()) return null
|
|
34
|
-
return new @@Provider@@DeliveryRecordedEvent(eventId, { receiptId: payload.receiptId })
|
|
36
|
+
return new @@Provider@@DeliveryRecordedEvent(eventId, { dedupeKey: payload.receiptId, receiptId: payload.receiptId })
|
|
35
37
|
}
|
|
36
38
|
}
|
|
@@ -1,23 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The suffix of a consumer's dead-letter queue: a delivery past its consumer's maxDeliveries moves from `<topic>__<consumer>` to
|
|
3
|
+
* `<topic>__<consumer>__dead`, which an operator reads and replays (BE-EVENT-BUS-4).
|
|
4
|
+
*/
|
|
5
|
+
export const DEAD_QUEUE_SUFFIX = "__dead"
|
|
6
|
+
|
|
7
|
+
/** The deliveries a consumer gets before its message moves to the dead-letter queue, when its subscription names none. */
|
|
8
|
+
export const DEFAULT_MAX_DELIVERIES = 5
|
|
9
|
+
|
|
10
|
+
/** What every event payload carries: the business key a consumer's inbox claims, stable across redelivery and replay. */
|
|
11
|
+
export interface EventPayload {
|
|
12
|
+
/** The business identity of the fact (an order id, a receipt id): the consumer claims (consumer, dedupeKey) once. */
|
|
13
|
+
readonly dedupeKey: string
|
|
14
|
+
}
|
|
15
|
+
|
|
1
16
|
/** The base of each typed event in modules/events: its stable identity, name and payload. */
|
|
2
17
|
export abstract class BaseEvent {
|
|
3
|
-
/**
|
|
18
|
+
/** The stable identity of this event, carried for logs and tracing. */
|
|
4
19
|
abstract readonly eventId: string
|
|
5
|
-
/** The event name listed by the service's vendored event contract. */
|
|
20
|
+
/** The event name listed by the service's vendored event contract; it is the pgmq topic the event is sent to. */
|
|
6
21
|
abstract readonly eventName: string
|
|
7
|
-
/** The fact that a reactor
|
|
8
|
-
abstract readonly payload:
|
|
22
|
+
/** The fact that a reactor processes after commit, with its business dedupe key. */
|
|
23
|
+
abstract readonly payload: EventPayload
|
|
9
24
|
}
|
|
10
25
|
|
|
11
26
|
/** Parsing either returns the event or refuses a mismatched envelope. */
|
|
12
27
|
export type ParsedEvent<Event extends BaseEvent> = Event | null
|
|
13
28
|
|
|
14
|
-
/** The class side of a typed event, shared by publishers and
|
|
29
|
+
/** The class side of a typed event, shared by publishers and consumers. */
|
|
15
30
|
export interface EventClass<Event extends BaseEvent> {
|
|
16
31
|
/** The contract's event name. */
|
|
17
32
|
readonly eventName: string
|
|
18
33
|
/** The contract's payload version. */
|
|
19
34
|
readonly version: number
|
|
20
|
-
/** Reads the event from its
|
|
35
|
+
/** Reads the event from its envelope. */
|
|
21
36
|
parse(envelope: unknown): ParsedEvent<Event>
|
|
22
37
|
}
|
|
23
38
|
|
|
@@ -25,21 +40,25 @@ export interface EventClass<Event extends BaseEvent> {
|
|
|
25
40
|
export interface EventDelivery<Event extends BaseEvent> {
|
|
26
41
|
/** The request id carried explicitly from intake to delivery. */
|
|
27
42
|
readonly correlationId: string
|
|
28
|
-
/** The
|
|
43
|
+
/** The stable identity of the event. */
|
|
29
44
|
readonly eventId: string
|
|
45
|
+
/** The consumer's queue, `<topic>__<consumer>`: the first half of the inbox claim. */
|
|
46
|
+
readonly consumer: string
|
|
47
|
+
/** The payload's business key: the second half of the inbox claim. */
|
|
48
|
+
readonly dedupeKey: string
|
|
30
49
|
/** The typed event. */
|
|
31
50
|
readonly event: Event
|
|
32
|
-
/** The number of
|
|
51
|
+
/** The number of deliveries, including this one. */
|
|
33
52
|
readonly attempt: number
|
|
34
53
|
}
|
|
35
54
|
|
|
36
55
|
/** Explicit request context carried independently of the business payload. */
|
|
37
56
|
export interface EventMetadata {
|
|
38
|
-
/** The originating request or promoted
|
|
57
|
+
/** The originating request or promoted event identity. */
|
|
39
58
|
readonly correlationId: string
|
|
40
59
|
}
|
|
41
60
|
|
|
42
|
-
/** The typed event's
|
|
61
|
+
/** The typed event's envelope, sent once to its topic in the publisher's transaction and routed to every bound consumer queue. */
|
|
43
62
|
export interface EventEnvelope {
|
|
44
63
|
/** The originating request context, independent of the business payload. */
|
|
45
64
|
readonly metadata: EventMetadata
|
|
@@ -48,5 +67,33 @@ export interface EventEnvelope {
|
|
|
48
67
|
/** The contract's event name. */
|
|
49
68
|
readonly eventName: string
|
|
50
69
|
/** The data of the fact. */
|
|
51
|
-
readonly payload:
|
|
70
|
+
readonly payload: EventPayload
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** One message of a consumer's queue, as event_bus_read returns it. */
|
|
74
|
+
export interface EventMessage {
|
|
75
|
+
/** The pgmq message id within its queue. */
|
|
76
|
+
readonly msgId: string
|
|
77
|
+
/** How many times the message has been read, including this read. */
|
|
78
|
+
readonly readCt: number
|
|
79
|
+
/** The envelope as the publisher sent it; untrusted until parsed. */
|
|
80
|
+
readonly message: unknown
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** What a reactor's subscription may tune; every field is a literal of the subscription. */
|
|
84
|
+
export interface EventSubscriptionOptions {
|
|
85
|
+
/** The deliveries before the message moves to the dead-letter queue. Default DEFAULT_MAX_DELIVERIES. */
|
|
86
|
+
readonly maxDeliveries?: number
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A delivery a consumer gave up on, as its dead-letter queue holds it. */
|
|
90
|
+
export interface DeadLetter {
|
|
91
|
+
/** The message id in the dead-letter queue: what requeue takes. */
|
|
92
|
+
readonly id: string
|
|
93
|
+
/** Why it was moved: its deliveries ran out, or its envelope was malformed. */
|
|
94
|
+
readonly reason: string
|
|
95
|
+
/** The deliveries it had. */
|
|
96
|
+
readonly readCount: number
|
|
97
|
+
/** The envelope as it was read. */
|
|
98
|
+
readonly message: unknown
|
|
52
99
|
}
|
|
@@ -1,9 +1,21 @@
|
|
|
1
1
|
import { injector } from "@modules/platform/composition"
|
|
2
2
|
import type { TypedParameterDecorator } from "@modules/platform/composition"
|
|
3
|
-
import
|
|
3
|
+
import { MODULE_OPTIONS_TOKEN } from "./event-bus.module-definition"
|
|
4
|
+
import type { EventBusOptions } from "./event-bus.options"
|
|
5
|
+
import type { EventBus, EventConsumerRegistry } from "./event-bus.port"
|
|
4
6
|
|
|
5
7
|
/** Token of the transactional EventBus publisher. */
|
|
6
8
|
export const EVENT_BUS: unique symbol = Symbol("platform.event-bus")
|
|
7
9
|
|
|
10
|
+
/** Token of the registry reactors subscribe their consumers to. */
|
|
11
|
+
export const EVENT_CONSUMER_REGISTRY: unique symbol = Symbol("platform.event-bus.consumers")
|
|
12
|
+
|
|
8
13
|
/** Injects the EventBus port. Parameter type: EventBus. */
|
|
9
14
|
export const InjectEventBus = (): TypedParameterDecorator<EventBus> => injector<EventBus>(EVENT_BUS)
|
|
15
|
+
|
|
16
|
+
/** Injects the consumer registry. Parameter type: EventConsumerRegistry. */
|
|
17
|
+
export const InjectEventConsumerRegistry = (): TypedParameterDecorator<EventConsumerRegistry> =>
|
|
18
|
+
injector<EventConsumerRegistry>(EVENT_CONSUMER_REGISTRY)
|
|
19
|
+
|
|
20
|
+
/** Injects the module's options. Parameter type: EventBusOptions. */
|
|
21
|
+
export const InjectEventBusOptions = (): TypedParameterDecorator<EventBusOptions> => injector<EventBusOptions>(MODULE_OPTIONS_TOKEN)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Log events of the event bus. */
|
|
2
|
+
export enum EventBusLogEvent {
|
|
3
|
+
/** A consumer threw; the message reappears after its visibility timeout. */
|
|
4
|
+
ConsumerFailed = "event-bus.consumer.failed",
|
|
5
|
+
/** A delivery ran out of deliveries or was malformed and moved to its consumer's dead-letter queue. */
|
|
6
|
+
DeliveryDead = "event-bus.delivery.dead",
|
|
7
|
+
/** A read of a consumer's queue failed; the loop reads again after its idle pause. */
|
|
8
|
+
ReadFailed = "event-bus.read.failed",
|
|
9
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { ConfigurableModuleBuilder } from "@nestjs/common"
|
|
2
2
|
import type { EventBusOptions } from "./event-bus.options"
|
|
3
3
|
|
|
4
|
-
export const { ConfigurableModuleClass, OPTIONS_TYPE } = new ConfigurableModuleBuilder<EventBusOptions>()
|
|
4
|
+
export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN, OPTIONS_TYPE } = new ConfigurableModuleBuilder<EventBusOptions>()
|
|
5
5
|
.setExtras({ isGlobal: false }, (definition, extras) => ({ ...definition, global: extras.isGlobal }))
|
|
6
6
|
.build()
|
|
@@ -1,19 +1,25 @@
|
|
|
1
1
|
import { Module } from "@nestjs/common"
|
|
2
2
|
import type { DynamicModule } from "@nestjs/common"
|
|
3
|
-
import { EVENT_BUS } from "./event-bus.decorators"
|
|
3
|
+
import { EVENT_BUS, EVENT_CONSUMER_REGISTRY } from "./event-bus.decorators"
|
|
4
4
|
import { ConfigurableModuleClass, OPTIONS_TYPE } from "./event-bus.module-definition"
|
|
5
5
|
import { EventBusService } from "./event-bus.service"
|
|
6
|
+
import { EventConsumerService } from "./event-consumer.service"
|
|
6
7
|
|
|
7
8
|
@Module({})
|
|
8
|
-
/** Provides the
|
|
9
|
+
/** Provides the lite event bus on pgmq: the transactional publisher and the read loops of the consumer groups. */
|
|
9
10
|
export class EventBusModule extends ConfigurableModuleClass {
|
|
10
|
-
/** Registers the
|
|
11
|
+
/** Registers the capability once per app. */
|
|
11
12
|
static register(options: typeof OPTIONS_TYPE): DynamicModule {
|
|
12
13
|
const base = super.register(options)
|
|
13
14
|
return {
|
|
14
15
|
...base,
|
|
15
|
-
providers: [
|
|
16
|
-
|
|
16
|
+
providers: [
|
|
17
|
+
...(base.providers ?? []),
|
|
18
|
+
{ provide: EVENT_BUS, useClass: EventBusService },
|
|
19
|
+
EventConsumerService,
|
|
20
|
+
{ provide: EVENT_CONSUMER_REGISTRY, useExisting: EventConsumerService },
|
|
21
|
+
],
|
|
22
|
+
exports: [EVENT_BUS, EVENT_CONSUMER_REGISTRY],
|
|
17
23
|
}
|
|
18
24
|
}
|
|
19
25
|
}
|