@starci/hfs 4.10.0 → 4.11.0
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 +18 -0
- package/db/drift.mjs +16 -15
- package/db/image.mjs +8 -0
- package/db/postgres.mjs +1 -1
- package/package.json +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +16 -6
- package/runtime/knowledge/hfs/rules.yaml +28 -4
- package/runtime/knowledge/hfs/slots.yaml +53 -12
- package/runtime/knowledge/patterns/be/idempotency.yaml +1 -1
- package/runtime/knowledge/patterns/be/jobs.yaml +3 -0
- package/runtime/knowledge/patterns/be/queues.yaml +12 -11
- package/runtime/knowledge/patterns/be/realtime.yaml +1 -1
- package/runtime/modules/kernel/failure-codes.yaml +11 -1
- package/runtime/modules/schemas/application-stacks.schema.yaml +1 -1
- package/runtime/scripts/hfs/architecture/entity-registry.mjs +53 -0
- package/runtime/scripts/hfs/architecture/owners.mjs +11 -0
- package/runtime/scripts/hfs/architecture/tiers.mjs +14 -3
- package/runtime/scripts/hfs/db-baseline-verb.mjs +45 -13
- package/runtime/scripts/hfs/manifest-shape.mjs +5 -1
- package/runtime/scripts/hfs/rules/database-config.mjs +1 -1
- package/runtime/scripts/hfs/rules/stacks-lite-backing.mjs +3 -4
- package/runtime/scripts/lib/swarm-stack.mjs +4 -6
- package/scaffold/add-queue-lite.mjs +24 -5
- package/stack/build.mjs +3 -1
- package/sync/index.mjs +19 -7
- package/templates/app/db-bootstrap/db/bootstrap.sql +7 -15
- package/templates/app/db-image/db/Dockerfile +8 -0
- package/templates/app/package-scripts/db.json +2 -1
- package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +4 -2
- package/templates/app/skeleton-lite/.starcistacks/dev/README.md +1 -1
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/stack.yaml +1 -1
- package/templates/app/skeleton-lite/.starcistacks/production/README.md +1 -1
- package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/stack.yaml +1 -1
- package/templates/app/skeleton-lite/README.md +2 -2
- package/templates/be/patterns/jobs/platform/jobs.log-events.ts.tpl +2 -0
- package/templates/be/patterns/jobs/platform-lite/job-tick.service.spec.ts.tpl +65 -0
- package/templates/be/patterns/jobs/platform-lite/job-tick.service.ts.tpl +78 -0
- package/templates/be/patterns/jobs/platform-lite/job-ticks.contracts.ts.tpl +16 -0
- package/templates/be/patterns/jobs/platform-lite/jobs.module.ts.tpl +40 -0
- package/templates/be/patterns/jobs/platform-lite/jobs.sql.tpl +44 -0
- package/templates/be/skeleton-lite/src/modules/platform/database/entities.ts +3 -2
|
@@ -4,6 +4,7 @@ import { relativePath, workspaceExportSources } from './typescript.mjs';
|
|
|
4
4
|
import { sourceLocation } from '../../lib/ts-ast.mjs';
|
|
5
5
|
import { globExpression } from '../../lib/glob.mjs';
|
|
6
6
|
import { posixPath } from '../../lib/path-key.mjs';
|
|
7
|
+
import { registryImporter, registrySlot } from './entity-registry.mjs';
|
|
7
8
|
|
|
8
9
|
function absolute(root, relative) {
|
|
9
10
|
return canonical(path.resolve(root, ...relative.split('/')));
|
|
@@ -68,6 +69,15 @@ function arrangesSchema(config, from, to) {
|
|
|
68
69
|
&& resolver.classifyPath(relativePath(config.root, to)).slot === 'be.persistence';
|
|
69
70
|
}
|
|
70
71
|
|
|
72
|
+
/**
|
|
73
|
+
* The entity registry (slot role entity-registry, entity-registry.mjs) is imported by deep path, and only from the files its slot's
|
|
74
|
+
* `importedBy` names: app composition and the test world. Any other importer goes through the owner's entry, which never exports it.
|
|
75
|
+
*/
|
|
76
|
+
function importsRegistry(config, from, to) {
|
|
77
|
+
const slot = registrySlot(config.hfs, relativePath(config.root, to));
|
|
78
|
+
return slot !== null && registryImporter(config.hfs, slot, relativePath(config.root, from));
|
|
79
|
+
}
|
|
80
|
+
|
|
71
81
|
function publicEntryViolations(config, context, owners, sourceFiles) {
|
|
72
82
|
const violations = [];
|
|
73
83
|
for (const owner of owners) {
|
|
@@ -104,6 +114,7 @@ function ownerImportViolations(config, context, owners) {
|
|
|
104
114
|
const bypass = privateOwnerChain(context, owners, edge);
|
|
105
115
|
if (!bypass) continue;
|
|
106
116
|
if (arrangesSchema(config, from, bypass.chain.at(-1))) continue;
|
|
117
|
+
if (bypass.chain.length === 2 && importsRegistry(config, from, bypass.chain.at(-1))) continue;
|
|
107
118
|
violations.push({
|
|
108
119
|
ruleId: 'ARCH_OWNER_EXPORT_BYPASS',
|
|
109
120
|
path: relativePath(config.root, from),
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { byCodeUnit } from '../../lib/list.mjs';
|
|
2
|
+
import { registryListImport, registrySlot } from './entity-registry.mjs';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* HFS checks 1 and 2 (knowledge/hfs/slots.yaml `tiers`):
|
|
@@ -8,7 +9,9 @@ import { byCodeUnit } from '../../lib/list.mjs';
|
|
|
8
9
|
* kind (slot field `trigger`: api, webhooks, realtime, saga, reactors, jobs, cli) importing a feature of another is
|
|
9
10
|
* BE_KIND_ISOLATION, whose fix is the event bus,
|
|
10
11
|
* an app never imports another app (BE_SERVICE_ISOLATION / FE_APP_ISOLATION), and a component layer imports only the layers after it;
|
|
11
|
-
*
|
|
12
|
+
* the entity registry naming a capability's `<c>Entities` from its public entry is the one platform-to-domain edge (entity-registry.mjs);
|
|
13
|
+
* 2. owner cycles: a strongly connected component of the owner graph, type-only imports included (ARCH_OWNER_CYCLE), the entity
|
|
14
|
+
* registry counted as a unit of its own,
|
|
12
15
|
* reported with the cycle path and the import that closes each hop.
|
|
13
16
|
* Importing inside one owner is always allowed; the public-entry rule stays with owners.mjs (ARCH_OWNER_EXPORT_BYPASS).
|
|
14
17
|
*/
|
|
@@ -138,15 +141,23 @@ function inspectTierEdge(graph, edge, tierRule, counts, violations, coverage) {
|
|
|
138
141
|
return;
|
|
139
142
|
}
|
|
140
143
|
coverage.edgesChecked += 1;
|
|
144
|
+
if (verdict.reason === 'tierDirection' && registryListImport(graph.resolver, edge)) return;
|
|
141
145
|
reportReasonedTierViolation(graph, edge, verdict, tierRule, counts, violations);
|
|
142
146
|
}
|
|
143
147
|
|
|
148
|
+
/**
|
|
149
|
+
* The cycle unit of a file: its owner unit, except the entity registry (entity-registry.mjs), which is a unit of its own. It sits in
|
|
150
|
+
* platform/database but is composition-level by role (it imports every capability's `<c>Entities`), so it never closes a cycle
|
|
151
|
+
* among capabilities through the owner it lives in.
|
|
152
|
+
*/
|
|
153
|
+
const cycleUnit = (graph, resolver, rel) => (registrySlot(resolver, rel) ? `entity-registry:${rel}` : graph.unit(rel));
|
|
154
|
+
|
|
144
155
|
function ownerAdjacency(graph, resolver) {
|
|
145
156
|
const adjacency = new Map();
|
|
146
157
|
const witness = new Map();
|
|
147
158
|
for (const edge of graph.edges) {
|
|
148
|
-
const a = graph
|
|
149
|
-
const b = graph
|
|
159
|
+
const a = cycleUnit(graph, resolver, edge.from);
|
|
160
|
+
const b = cycleUnit(graph, resolver, edge.to);
|
|
150
161
|
if (!a || !b || a === b) continue;
|
|
151
162
|
if (resolver.tierOf(edge.from) === 'none' || resolver.tierOf(edge.to) === 'none') continue;
|
|
152
163
|
if (!adjacency.has(a)) adjacency.set(a, new Set());
|
|
@@ -42,15 +42,34 @@ export function dbAppRoot(cwd, opts) {
|
|
|
42
42
|
return root;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
/**
|
|
45
|
+
/** A refusal that lists every offending file with the rule it broke, one distinct `<file>: <reason>` line each. */
|
|
46
|
+
const offendersError = (heading, offenders) => new DbVerbError(`${heading}:\n${[...new Set(offenders)].map((line) => ` ${line}`).join('\n')}`);
|
|
47
|
+
|
|
48
|
+
/** One rule finding as an offender line: its file, its message and its code. */
|
|
49
|
+
const findingLine = (item) => `${item.message.startsWith(item.path) ? item.message : `${item.path}: ${item.message}`} (${item.code})`;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Why one SQL filename cannot join its chain, or null. The active chain holds R213 (`<ts14>_<kebab>.sql`, a valid UTC
|
|
53
|
+
* stamp; a stamp after now is no breach). The archive is frozen history (R257): it needs only the leading stamp that
|
|
54
|
+
* orders it, so snake_case names, impossible times and shared stamps of a retired chain stay as they were committed.
|
|
55
|
+
*/
|
|
56
|
+
function chainProblem(directory, name) {
|
|
57
|
+
const match = /^(\d{14})_/.exec(name);
|
|
58
|
+
if (directory === MIGRATIONS_ARCHIVE_DIR) return match ? null : 'an archived name starts with its 14-digit stamp and an underscore (R257)';
|
|
59
|
+
if (!isMigrationName(name)) return 'name is not <ts14>_<kebab>.sql (R213)';
|
|
60
|
+
if (migrationStamp(match[1]) === null) return `stamp ${match[1]} is no valid UTC time (R213)`;
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Immediate SQL files only, never secrets or credential files, sorted by full name; every offender is named before the refusal. */
|
|
46
65
|
export function dbSqlFiles(root, directory = MIGRATIONS_DIR) {
|
|
47
66
|
const dir = path.join(root, directory);
|
|
48
67
|
if (!fs.existsSync(dir)) return [];
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
68
|
+
const sqlNames = fs.readdirSync(dir, { withFileTypes: true }).filter((entry) => entry.isFile() && entry.name.endsWith('.sql')).map((entry) => entry.name);
|
|
69
|
+
const offenders = sqlNames.flatMap((name) => { const problem = chainProblem(directory, name); return problem ? [`${directory}/${name}: ${problem}`] : []; });
|
|
70
|
+
if (offenders.length) throw offendersError('migration filenames break their chain rules', offenders.sort());
|
|
71
|
+
return sqlNames.map((name) => ({ file: `${directory}/${name}`, name, stamp: name.slice(0, 14) }))
|
|
72
|
+
.sort((left, right) => left.name < right.name ? -1 : Number(left.name > right.name));
|
|
54
73
|
}
|
|
55
74
|
|
|
56
75
|
/** The database of `--url-env` (default DATABASE_URL): the URL without its password and the password for PGPASSWORD. */
|
|
@@ -103,12 +122,17 @@ function baselinePlan(root, opts, now) {
|
|
|
103
122
|
const active = dbSqlFiles(root);
|
|
104
123
|
if (!active.length) throw new DbVerbError('baseline requires an existing active migration chain');
|
|
105
124
|
const archived = dbSqlFiles(root, MIGRATIONS_ARCHIVE_DIR);
|
|
106
|
-
const
|
|
107
|
-
const
|
|
125
|
+
const archivedNames = new Set(archived.map((entry) => entry.name));
|
|
126
|
+
const twice = active.filter((entry) => archivedNames.has(entry.name)).map((entry) => `${entry.file}: also archived as ${MIGRATIONS_ARCHIVE_DIR}/${entry.name}; a migration is active or archived, never both (R257)`);
|
|
127
|
+
if (twice.length) throw offendersError('baseline would overwrite an existing migration', twice);
|
|
128
|
+
const chain = [...active, ...archived].sort((left, right) => left.stamp < right.stamp ? -1 : Number(left.stamp > right.stamp) || (left.name < right.name ? -1 : Number(left.name > right.name)));
|
|
129
|
+
// The new version sorts after every file; the header names the last valid stamp, the boundary R257 judges.
|
|
130
|
+
const boundary = chain.at(-1);
|
|
131
|
+
const last = chain.filter((entry) => migrationStamp(entry.stamp) !== null).at(-1)?.stamp ?? boundary.stamp;
|
|
108
132
|
const stamp = opts.stamp ?? now().toISOString().replace(/\D/g, '').slice(0, 14);
|
|
109
|
-
if (!/^\d{14}$/.test(stamp) || migrationStamp(stamp) === null || stamp <=
|
|
133
|
+
if (!/^\d{14}$/.test(stamp) || migrationStamp(stamp) === null || stamp <= boundary.stamp) throw new DbVerbError(`--stamp must be a valid UTC version after the archive boundary ${boundary.stamp} (${boundary.file})`);
|
|
110
134
|
const file = `${MIGRATIONS_DIR}/${stamp}_baseline.sql`;
|
|
111
|
-
if (fs.existsSync(path.join(root, file))
|
|
135
|
+
if (fs.existsSync(path.join(root, file))) throw new DbVerbError(`baseline would overwrite an existing migration: ${file}`);
|
|
112
136
|
return { active, last, stamp, file };
|
|
113
137
|
}
|
|
114
138
|
|
|
@@ -255,12 +279,19 @@ function baselineStatement(state, stmt, sql) {
|
|
|
255
279
|
|
|
256
280
|
function baselineMetadata(root, plan, files) {
|
|
257
281
|
const queues = databaseQueueDeclarations({ repoRoot: root, files, all: files });
|
|
258
|
-
if (queues.findings.length) throw
|
|
282
|
+
if (queues.findings.length) throw offendersError('baseline requires valid migration-owned queues, grants and cron declarations', queues.findings.map(findingLine));
|
|
259
283
|
const state = { roles: new Map(), extensions: new Map() };
|
|
284
|
+
const offenders = [];
|
|
260
285
|
for (const migration of plan.active) {
|
|
261
286
|
const text = fs.readFileSync(path.join(root, migration.file), 'utf8');
|
|
262
|
-
|
|
287
|
+
try {
|
|
288
|
+
for (const entry of parsed(text).stmts ?? []) baselineStatement(state, entry.stmt, statementSql(text, entry));
|
|
289
|
+
} catch (error) {
|
|
290
|
+
if (!(error instanceof DbVerbError)) throw error;
|
|
291
|
+
offenders.push(`${migration.file}: ${error.message}`);
|
|
292
|
+
}
|
|
263
293
|
}
|
|
294
|
+
if (offenders.length) throw offendersError('baseline cannot rebuild the active chain from a schema-only dump', offenders);
|
|
264
295
|
return { ...queues, roles: [...state.roles.values()].flat().join(''), extensions: [...state.extensions.values()].join('') };
|
|
265
296
|
}
|
|
266
297
|
|
|
@@ -279,7 +310,8 @@ export function dbBaselineMain(argv, { cwd = invocationDir(), stdout = (text) =>
|
|
|
279
310
|
const root = dbAppRoot(cwd, opts);
|
|
280
311
|
const plan = baselinePlan(root, opts, now);
|
|
281
312
|
const files = baselineFiles(root);
|
|
282
|
-
|
|
313
|
+
const sourceFindings = baselineSourceFindings({ repoRoot: root, files, all: files, git: ports.git });
|
|
314
|
+
if (sourceFindings.length) throw offendersError('baseline sources and archives must match committed migration history', sourceFindings.map(findingLine));
|
|
283
315
|
const metadata = baselineMetadata(root, plan, files);
|
|
284
316
|
if (dbPlanMain(['--json', '--url-env', urlEnv], { ...ports, cwd: root, stdout: () => {}, stderr: () => {} }) !== 0) throw new DbVerbError('baseline requires a clean database plan with every migration already applied');
|
|
285
317
|
const schema = runDbPostgres(root, { kind: 'dump' }, ports, urlEnv);
|
|
@@ -87,7 +87,9 @@ export const MANIFEST_KINDS = [APP_KIND, RUNTIME_KIND];
|
|
|
87
87
|
export const manifestKind = (m) => (isPlainObject(m) && m.kind !== undefined ? m.kind : APP_KIND);
|
|
88
88
|
const strList = (v) => stringList(v);
|
|
89
89
|
|
|
90
|
-
const APP_SLOT_KEYS = ['id', 'profiles', 'path', 'presence', 'tracked', 'tier', 'tests', 'owner', 'appKind', 'minInstances', 'requiredWhen', 'requiredInstances', 'requires', 'pattern', 'trigger', 'allows', 'forbids', 'layers', 'kinds', 'roles', 'composedBy', 'budget', 'managedBy', 'liteManagedBy', 'rules', 'goesTo', 'why', 'since', 'perConnection', 'parent', 'editions', 'editionWhy', 'litePresence', 'lite', 'provider', 'coverage', 'entries', 'outcomeHome', 'templateNa', 'exemplifiedBy', 'rewrittenBy'];
|
|
90
|
+
const APP_SLOT_KEYS = ['id', 'profiles', 'path', 'presence', 'tracked', 'tier', 'tests', 'owner', 'appKind', 'minInstances', 'requiredWhen', 'requiredInstances', 'requires', 'pattern', 'trigger', 'allows', 'forbids', 'layers', 'kinds', 'roles', 'composedBy', 'budget', 'managedBy', 'liteManagedBy', 'rules', 'goesTo', 'why', 'since', 'perConnection', 'parent', 'editions', 'editionWhy', 'litePresence', 'lite', 'provider', 'coverage', 'entries', 'outcomeHome', 'templateNa', 'exemplifiedBy', 'role', 'importedBy', 'rewrittenBy'];
|
|
91
|
+
/** The roles a file slot may play that a rule reads (`role`): the entity registry of persistence.md BE-PERSISTENCE-3 (architecture/entity-registry.mjs). */
|
|
92
|
+
const SLOT_ROLES = ['entity-registry'];
|
|
91
93
|
/** The fields a slot's `lite` overlay may hold: the same keys it would carry in the slot body, resolved under edition lite. */
|
|
92
94
|
const LITE_OVERLAY_KEYS = ['path', 'requires', 'allows', 'forbids', 'minInstances', 'requiredInstances', 'why'];
|
|
93
95
|
/** A runtime slot has no app kind, side composition, layer or managed template; it may name the generator of a generated copy. */
|
|
@@ -250,6 +252,8 @@ function slotEntryProblems(slot, at) {
|
|
|
250
252
|
else if (Array.isArray(slot.allows) && slot.entries.some((entry) => !slot.allows.includes(entry))) bad.push(`${at}: every entry must be one of the names the slot allows`);
|
|
251
253
|
}
|
|
252
254
|
if (slot.outcomeHome !== undefined && !(slot.outcomeHome === true && typeof slot.path === 'string' && slot.path && !slot.path.endsWith('/'))) bad.push(`${at}: outcomeHome is true and belongs to a file slot (the one file that declares the repository's Outcome union)`);
|
|
255
|
+
if (slot.role !== undefined && !(SLOT_ROLES.includes(slot.role) && typeof slot.path === 'string' && slot.path && !slot.path.endsWith('/'))) bad.push(`${at}: role is one of ${SLOT_ROLES.join(', ')} and belongs to a file slot`);
|
|
256
|
+
if (slot.importedBy !== undefined && !(slot.role !== undefined && strList(slot.importedBy) && slot.importedBy.length && new Set(slot.importedBy).size === slot.importedBy.length && slot.importedBy.every(relPath))) bad.push(`${at}: importedBy is a non-empty list of unique relative path patterns of the only files that import the slot's file by deep path, on a slot with a role`);
|
|
253
257
|
return bad;
|
|
254
258
|
}
|
|
255
259
|
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
import { databaseExecutableTokens, databaseSqlStatements, databaseSqlTokens, destructiveMigrationFindings } from './database-migrations.mjs';
|
|
8
8
|
import { byCodeUnit } from '../../lib/list.mjs';
|
|
9
9
|
|
|
10
|
-
const DEFAULT_EXTENSIONS = ['
|
|
10
|
+
const DEFAULT_EXTENSIONS = ['pgcrypto', 'pg_trgm', 'vector', 'uuid-ossp', 'unaccent', 'btree_gist'];
|
|
11
11
|
const word = (token, value) => token?.t === 'word' && token.v === value;
|
|
12
12
|
const identifier = (token) => token && ['word', 'ident'].includes(token.t) ? token.v : null;
|
|
13
13
|
|
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
// R276: lite runs exactly its own PostgreSQL (the db component, image
|
|
1
|
+
// R276: lite runs exactly its own PostgreSQL (the db component, the image its managed db/Dockerfile builds) with pgvector, admits only the backing services available to its edition,
|
|
2
2
|
// and runs a declared Redis as one service with AOF fsynced at least every second on a named volume and no eviction
|
|
3
3
|
// (ruling:R212/case-10, owner:lite-vector-is-pgvector); sidecar SQLite persists on declared named volumes.
|
|
4
4
|
import { isPlainObject as plain } from '../../../engine/plain-object.mjs';
|
|
5
5
|
import { composeEntries } from '../../lib/compose-values.mjs';
|
|
6
6
|
import { repositoryOf } from '../../lib/stack-services.mjs';
|
|
7
|
-
import { isLiteDatabaseImage } from '../../lib/swarm-stack.mjs';
|
|
8
7
|
import { loadSlotManifest } from '../slots.mjs';
|
|
9
8
|
import { judgedInEdition } from '../edition-slots.mjs';
|
|
10
9
|
import { readJson } from './read.mjs';
|
|
@@ -44,9 +43,9 @@ function imageIssues(components, services, { catalog, declared }) {
|
|
|
44
43
|
for (const [id, entry] of Object.entries(inventory)) {
|
|
45
44
|
if (!present(entry?.image)) continue;
|
|
46
45
|
const service = serviceOfImage(catalog, entry.image);
|
|
47
|
-
if (DATABASE_IMAGE.test(repositoryOf(entry.image))
|
|
46
|
+
if (DATABASE_IMAGE.test(repositoryOf(entry.image)))
|
|
48
47
|
errors.push({ code: 'STACKS_LITE_SELFHOSTED_DB', at: `${prefix}.${id}.image`,
|
|
49
|
-
message: 'lite runs one database, the db component of image
|
|
48
|
+
message: 'lite runs one database, the db component of the image db/Dockerfile builds (the official postgres image plus pgvector); no other Postgres or Mongo image' });
|
|
50
49
|
else if (service !== null && !judgedInEdition(catalog[service], 'lite'))
|
|
51
50
|
errors.push({ code: 'STACKS_LITE_SELFHOSTED_DB', at: `${prefix}.${id}.image`,
|
|
52
51
|
message: `lite must not run ${service}; vector search uses pgvector in the db service (owner:lite-vector-is-pgvector)` });
|
|
@@ -11,8 +11,6 @@ const BACKING_IMAGE_BROKERS_AND_OTHERS = '(?:cp-)?kafka(?:-native)?|keycloak|min
|
|
|
11
11
|
const BACKING_IMAGE = new RegExp(`^(?:${BACKING_IMAGE_SQL_AND_CACHE}|${BACKING_IMAGE_BROKERS_AND_OTHERS})$`, 'i');
|
|
12
12
|
const mapping = value => plain(value) ? value : {};
|
|
13
13
|
|
|
14
|
-
/** True for the one database image a lite app runs (its db component): supabase/postgres, pinned by digest. */
|
|
15
|
-
export const isLiteDatabaseImage = image => typeof image === 'string' && /^supabase\/postgres(?::[^@]+)?@sha256:[a-f0-9]{64}$/i.test(image);
|
|
16
14
|
const present = value => typeof value === 'string' && Boolean(value.trim());
|
|
17
15
|
const configured = value => Object.keys(mapping(value)).length > 0;
|
|
18
16
|
const schedulerImage = image => present(image) && /(?:^|\/)swarm-cronjob(?:[:@]|$)/.test(image);
|
|
@@ -52,8 +50,8 @@ export function swarmInventoryIssues({ components, lite = false }) {
|
|
|
52
50
|
const add = (at, message) => errors.push({ at, message });
|
|
53
51
|
for (const [id, entry] of Object.entries(catalog)) {
|
|
54
52
|
const component = mapping(entry);
|
|
55
|
-
if (lite && present(component.image) && BACKING_IMAGE.test(repositoryOf(component.image))
|
|
56
|
-
add(`components.${id}.image`, 'lite runs no backing image
|
|
53
|
+
if (lite && present(component.image) && BACKING_IMAGE.test(repositoryOf(component.image)))
|
|
54
|
+
add(`components.${id}.image`, 'lite runs no backing image; its db component runs the image db/Dockerfile builds, storage is the external S3 endpoint, identity the API');
|
|
57
55
|
jobDeclarationIssues(id, component, catalog, add);
|
|
58
56
|
}
|
|
59
57
|
return errors;
|
|
@@ -120,8 +118,8 @@ export function swarmModelIssues({ model, components, bindings, lite = false })
|
|
|
120
118
|
add(`services.${id}.ports`, 'Swarm ports must be an array of published port definitions');
|
|
121
119
|
else if (service.ports?.length && component.role !== 'ingress')
|
|
122
120
|
add(`services.${id}.ports`, 'only a component with role ingress may publish Swarm ports');
|
|
123
|
-
if (lite && present(service.image) && BACKING_IMAGE.test(repositoryOf(service.image))
|
|
124
|
-
add(`services.${id}.image`, 'lite stack runs no backing image
|
|
121
|
+
if (lite && present(service.image) && BACKING_IMAGE.test(repositoryOf(service.image)))
|
|
122
|
+
add(`services.${id}.image`, 'lite stack runs no backing image; its db service runs the image db/Dockerfile builds');
|
|
125
123
|
jobServiceIssues(id, service, component, services, bound, add);
|
|
126
124
|
}
|
|
127
125
|
for (const id of Object.keys(bound)) {
|
|
@@ -39,8 +39,23 @@ const LITE_TEMPLATES = Object.freeze({
|
|
|
39
39
|
'jobs/platform/jobs.index.ts.tpl': 'jobs/platform-lite/jobs.index.ts.tpl',
|
|
40
40
|
'jobs/platform/job-claim.service.ts.tpl': 'jobs/platform-lite/job-claim.service.ts.tpl',
|
|
41
41
|
'jobs/platform/job-claim.service.spec.ts.tpl': 'jobs/platform-lite/job-claim.service.spec.ts.tpl',
|
|
42
|
+
// The jobs module composes the one tick scheduler (R335, ruling:lite-official-postgres-image): ScheduleModule.forRoot() and JobTickService.
|
|
43
|
+
'jobs/platform/jobs.module.ts.tpl': 'jobs/platform-lite/jobs.module.ts.tpl',
|
|
42
44
|
});
|
|
43
45
|
|
|
46
|
+
/** The tick scheduler of platform/jobs the lite jobs capability adds beside full's tree: the ticks as data, the scheduler and its spec (R335). */
|
|
47
|
+
const TICK_FILES = Object.freeze([
|
|
48
|
+
['be/src/modules/platform/jobs/job-ticks.contracts.ts', 'jobs/platform-lite/job-ticks.contracts.ts.tpl'],
|
|
49
|
+
['be/src/modules/platform/jobs/job-tick.service.ts', 'jobs/platform-lite/job-tick.service.ts.tpl'],
|
|
50
|
+
['be/src/modules/platform/jobs/job-tick.service.spec.ts', 'jobs/platform-lite/job-tick.service.spec.ts.tpl'],
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
/** The tick files of a jobs capability the verb adds for the first time; an app that already has platform/jobs keeps its own. */
|
|
54
|
+
function tickFiles({ beRoot, patterns }) {
|
|
55
|
+
if (!patterns.includes('fenced-job') || fs.existsSync(path.join(beRoot, 'src', 'modules', 'platform', 'jobs', 'index.ts'))) return [];
|
|
56
|
+
return TICK_FILES.map(([relative, source]) => ({ relative, text: template(`be/patterns/${source}`) }));
|
|
57
|
+
}
|
|
58
|
+
|
|
44
59
|
/**
|
|
45
60
|
* The persistence files lite leaves out: the TypeORM schema (its authority is db/migrations) and the SQL text and row files (the
|
|
46
61
|
* statements are database functions of the lite migration, called through callDbFunction; R334 and the be.persistence lite overlay).
|
|
@@ -102,20 +117,24 @@ function migrationFiles({ repoRoot, beRoot, patterns, now }) {
|
|
|
102
117
|
|
|
103
118
|
/** The queue imports bullmq, which loads ioredis as an optional peer and cannot connect without it. */
|
|
104
119
|
const QUEUE_CLIENTS = Object.freeze(['bullmq', 'ioredis']);
|
|
120
|
+
/** The jobs capability's tick scheduler imports @nestjs/schedule and its CronJob (R335). */
|
|
121
|
+
const SCHEDULER_CLIENTS = Object.freeze(['@nestjs/schedule', 'cron']);
|
|
105
122
|
|
|
106
123
|
/**
|
|
107
|
-
* hfs.json declares redis in sides.be.backing; the package.json gains the queue's clients
|
|
124
|
+
* hfs.json declares redis in sides.be.backing; the package.json gains the queue's clients (and, for fenced-job, the tick
|
|
125
|
+
* scheduler's) at their canon pins, and the managed
|
|
108
126
|
* development scripts of the declared Redis that `starci app sync` renders for it. Each is added when it is missing, so a Redis an
|
|
109
127
|
* earlier capability declared (a cache on ioredis) still gains bullmq; a file already complete is left alone.
|
|
110
128
|
*/
|
|
111
|
-
function declarationPatches(repoRoot, project) {
|
|
129
|
+
function declarationPatches(repoRoot, project, patterns) {
|
|
112
130
|
const file = path.join(repoRoot, 'hfs.json');
|
|
113
131
|
const declaration = JSON.parse(read(file));
|
|
114
132
|
declaration.sides.be.backing = [...new Set([...(declaration.sides.be.backing ?? []), 'redis'])].sort(byCodeUnit);
|
|
115
133
|
const packageFile = path.join(repoRoot, 'package.json');
|
|
116
134
|
const pkg = JSON.parse(read(packageFile));
|
|
117
135
|
const pins = bundledPins();
|
|
118
|
-
const
|
|
136
|
+
const clients = [...QUEUE_CLIENTS, ...(patterns.includes('fenced-job') ? SCHEDULER_CLIENTS : [])];
|
|
137
|
+
const missing = clients.filter((name) => typeof pkg.dependencies?.[name] !== 'string');
|
|
119
138
|
for (const name of missing) if (!pins[name]) throw new ScaffoldError('HFS_SCAFFOLD_PIN_MISSING', `${name} has no canon pin in knowledge/hfs/canon-pins.yaml`);
|
|
120
139
|
if (missing.length)
|
|
121
140
|
pkg.dependencies = Object.fromEntries(Object.entries({ ...pkg.dependencies, ...Object.fromEntries(missing.map((name) => [name, pins[name].version])) }).sort(([a], [b]) => a.localeCompare(b)));
|
|
@@ -331,9 +350,9 @@ export function planLiteQueue({ repoRoot, repo, noun, patterns, now }) {
|
|
|
331
350
|
const beRoot = path.join(repoRoot, 'be');
|
|
332
351
|
const migrations = migrationFiles({ repoRoot, beRoot, patterns, now });
|
|
333
352
|
const database = skeletonFiles(repoRoot);
|
|
334
|
-
const declaration = declarationPatches(repoRoot, repo.project);
|
|
353
|
+
const declaration = declarationPatches(repoRoot, repo.project, patterns);
|
|
335
354
|
const stack = stackFiles(repoRoot, { project: repo.project, consumers: queueServicesOf(repo) });
|
|
336
|
-
const created = [...migrations, ...database.created, ...stack.created];
|
|
355
|
+
const created = [...migrations, ...tickFiles({ beRoot, patterns }), ...database.created, ...stack.created];
|
|
337
356
|
const patches = [...database.patches, ...declaration, ...stack.patches];
|
|
338
357
|
const clashes = created.filter((entry) => fs.existsSync(path.join(repoRoot, ...entry.relative.split('/')))).map((entry) => entry.relative);
|
|
339
358
|
if (clashes.length) throw new ScaffoldError('HFS_ADD_EXISTS', `add ${noun} would overwrite ${clashes.join(', ')}`);
|
package/stack/build.mjs
CHANGED
|
@@ -13,6 +13,7 @@ import { buildArgs } from './build-args.mjs';
|
|
|
13
13
|
import { firstLine, lines as splitLines, tool } from './context.mjs';
|
|
14
14
|
import { failure, refusal } from './errors.mjs';
|
|
15
15
|
import { requireAgeIdentity } from './sealed.mjs';
|
|
16
|
+
import { DB_DOCKERFILE, DB_IMAGE_APP } from '../db/image.mjs';
|
|
16
17
|
import { scanImage } from './image-scan.mjs';
|
|
17
18
|
|
|
18
19
|
const RELEASE_TAG = /^v\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/u;
|
|
@@ -44,8 +45,9 @@ function ownerOf(ctx, config) {
|
|
|
44
45
|
return match[1].toLowerCase();
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
/** The Dockerfile of an app: be/apps/<app>/Dockerfile or fe/apps/<app>/Dockerfile; ambiguity and absence are refusals. */
|
|
48
|
+
/** The Dockerfile of an app: be/apps/<app>/Dockerfile or fe/apps/<app>/Dockerfile, or db/Dockerfile for the db image; ambiguity and absence are refusals. */
|
|
48
49
|
function dockerfileOf(config, app) {
|
|
50
|
+
if (app === DB_IMAGE_APP && fs.existsSync(path.join(config.repoRoot, DB_DOCKERFILE))) return DB_DOCKERFILE;
|
|
49
51
|
const found = SIDES.map((side) => `${side}/apps/${app}/Dockerfile`).filter((file) => fs.existsSync(path.join(config.repoRoot, file)));
|
|
50
52
|
if (found.length === 0) throw refusal('STACK_BUILD_NO_DOCKERFILE', `no Dockerfile for app ${app} (be/apps/${app}/Dockerfile or fe/apps/${app}/Dockerfile)`);
|
|
51
53
|
if (found.length > 1) throw refusal('STACK_BUILD_AMBIGUOUS', `app ${app} has a Dockerfile on both sides: ${found.join(', ')}`);
|
package/sync/index.mjs
CHANGED
|
@@ -30,6 +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
34
|
import { byCodeUnit } from '../report/order.mjs';
|
|
34
35
|
import { isRuntimeExampleApp } from '../runtime/scripts/lib/runtime-example.mjs';
|
|
35
36
|
import { ciWorkflowCommands } from './ci-commands.mjs';
|
|
@@ -156,9 +157,16 @@ function trailingNewline(text) {
|
|
|
156
157
|
return text.slice(0, end) + '\n';
|
|
157
158
|
}
|
|
158
159
|
|
|
160
|
+
/**
|
|
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 only PGVECTOR_PACKAGE from PGDG.
|
|
163
|
+
*/
|
|
164
|
+
export const DB_BASE_IMAGE = 'postgres:17.11@sha256:31ba9c94a03ada9988f2bb493a22a75eef3a185884533e5cdbcd498ea835b08f';
|
|
165
|
+
/** The PGDG package of pgvector at the exact version the db image installs (the one extension the image does not ship). */
|
|
166
|
+
export const PGVECTOR_PACKAGE = 'postgresql-17-pgvector=0.8.7-1.pgdg13+1';
|
|
167
|
+
/** The local tag the development db container and the drift scratch database run, built from the app's db/Dockerfile. */
|
|
168
|
+
export const dbLocalImage = project => `${project}-db:local`;
|
|
159
169
|
/** The digest-pinned image of a lite app's declared Redis, in every environment that runs it (ruling:R212/case-10). */
|
|
160
|
-
/** The pinned image of a lite app's db service (contexts/stack/pins.yaml images): PostgreSQL with pg_cron, pg_net, pgmq and pgvector. */
|
|
161
|
-
export const DB_IMAGE = 'supabase/postgres:17.11.0.006@sha256:c141fd17ac6a76c4323e90b3aee8ff1a23d2e89e547c2b963cfadc309c079cc2';
|
|
162
170
|
export const REDIS_IMAGE = 'redis:7@sha256:c6eabf748fc7a61dbb5a705c78bcf3d6377b1127a97d0ce965c11c44ba46896f';
|
|
163
171
|
|
|
164
172
|
const redisDevText = project => render(readBundled('app/package-scripts/redis-dev.json'), { project, redisImage: REDIS_IMAGE });
|
|
@@ -322,17 +330,19 @@ function imageEntries(app, source) {
|
|
|
322
330
|
}
|
|
323
331
|
|
|
324
332
|
/**
|
|
325
|
-
* The build matrix of the images workflow: one entry per declared app (be then fe), each with the image name and its Dockerfile. The workflow
|
|
333
|
+
* The build matrix of the images workflow: one entry per declared app (be then fe, then a lite app's db image), each with the image name and its Dockerfile. The workflow
|
|
326
334
|
* runs on the release tag, so every image is built there; no path filter narrows it. A YAML block mapping per entry, indented for
|
|
327
335
|
* `strategy.matrix.include`.
|
|
328
336
|
*/
|
|
329
337
|
export function imageMatrix(app, source) {
|
|
330
338
|
const entry = (side, item) => ` - name: ${item.name}\n file: ${dockerfilePath(side, item.name)}`;
|
|
331
|
-
|
|
339
|
+
const db = app.edition === 'lite' ? [` - name: ${DB_IMAGE_APP}\n file: ${DB_DOCKERFILE}`] : [];
|
|
340
|
+
return [...imageEntries(app, source).map(image => entry(image.side, image.entry)), ...db].join('\n');
|
|
332
341
|
}
|
|
333
342
|
|
|
334
|
-
/** The Dockerfile of the app a deploy image names (be first, then fe); an app hfs.json does not declare cannot be built by the managed workflow. */
|
|
343
|
+
/** The Dockerfile of the app a deploy image names (be first, then fe, then a lite app's db image); an app hfs.json does not declare cannot be built by the managed workflow. */
|
|
335
344
|
function stackDockerfile(app, name) {
|
|
345
|
+
if (app.edition === 'lite' && name === DB_IMAGE_APP) return DB_DOCKERFILE;
|
|
336
346
|
const side = ['be', 'fe'].find(which => app.sides[which].apps.some(entry => entry.name === name));
|
|
337
347
|
if (!side) throw new SyncError('HFS_SYNC_STACK_IMAGE', `deploy.images names the app ${name}, which hfs.json does not declare under be or fe`);
|
|
338
348
|
return dockerfilePath(side, name);
|
|
@@ -375,8 +385,10 @@ export function variables(app, scope, presets, sonarKey, manifest = loadSlotMani
|
|
|
375
385
|
project: app.project,
|
|
376
386
|
appBinary: APP_BINARY,
|
|
377
387
|
appScripts: appScripts(app, source),
|
|
378
|
-
// A lite app's own database runs in development as one container of the
|
|
379
|
-
dbImage:
|
|
388
|
+
// A lite app's own database runs in development as one container of the image its db/Dockerfile builds (ruling:lite-official-postgres-image).
|
|
389
|
+
dbImage: dbLocalImage(app.project),
|
|
390
|
+
dbBaseImage: DB_BASE_IMAGE,
|
|
391
|
+
pgvectorPackage: PGVECTOR_PACKAGE,
|
|
380
392
|
// A lite app's declared Redis (sides.be.backing, ruling:R212/case-10) runs in development as one container of its pinned image.
|
|
381
393
|
backingScripts: lite && (app.sides.be.backing ?? []).includes('redis') ? redisDevText(app.project) : '',
|
|
382
394
|
// turbo builds every fe app workspace; ^build (turbo.json) builds the packages each app imports first, and caches both.
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
-- Managed by starci app sync (slot app.db.bootstrap); do not edit: run "npx starci app sync --write".
|
|
2
|
-
-- The pre-migration bootstrap of a lite app's self-hosted PostgreSQL (ruling lite-self-hosted-postgres-passport-minio)
|
|
3
|
-
--
|
|
4
|
-
-- auth.sessions, auth.uid/jwt/role) and the `storage`
|
|
5
|
-
--
|
|
6
|
-
-- of integrations/object-storage. This file gives the migrations those objects, EMPTY, and nothing else
|
|
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
|
|
4
|
+
-- Storage created: the browser roles, the `auth` schema (auth.users, auth.identities, auth.sessions, auth.uid/jwt/role) and the `storage`
|
|
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 nothing else; nothing
|
|
7
|
+
-- else of Supabase (supabase_admin, the `extensions` schema, the realtime publication) exists. It is idempotent; `starci app db migrate`
|
|
7
8
|
-- applies it as the database owner before the first migration of an empty database.
|
|
8
9
|
--
|
|
9
10
|
-- auth.users keeps the GoTrue column set so restored rows and foreign keys from public tables stay valid; encrypted_password holds bcrypt hashes.
|
|
10
11
|
-- 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
|
|
11
12
|
-- every policy that names them denies the browser roles, which have no door in lite.
|
|
12
13
|
|
|
13
|
-
create schema if not exists extensions;
|
|
14
14
|
create schema if not exists auth;
|
|
15
15
|
create schema if not exists storage;
|
|
16
16
|
|
|
@@ -23,9 +23,8 @@ end
|
|
|
23
23
|
$roles$;
|
|
24
24
|
|
|
25
25
|
grant usage on schema auth, storage to anon, authenticated, service_role;
|
|
26
|
-
grant usage on schema extensions to anon, authenticated, service_role;
|
|
27
26
|
|
|
28
|
-
-- auth.users: the full GoTrue column set (
|
|
27
|
+
-- auth.users: the full GoTrue column set (a copy an earlier database carries is extended in place).
|
|
29
28
|
create table if not exists auth.users (id uuid primary key);
|
|
30
29
|
alter table auth.users
|
|
31
30
|
add column if not exists instance_id uuid,
|
|
@@ -130,10 +129,3 @@ alter table storage.buckets enable row level security;
|
|
|
130
129
|
|
|
131
130
|
grant select, insert, update, delete on storage.objects, storage.buckets to authenticated, service_role;
|
|
132
131
|
grant select on storage.objects, storage.buckets to anon;
|
|
133
|
-
|
|
134
|
-
-- Replication publication the baseline adds tables to (the image ships it; a bare PostgreSQL does not).
|
|
135
|
-
do $pub$
|
|
136
|
-
begin
|
|
137
|
-
if not exists (select 1 from pg_publication where pubname = 'supabase_realtime') then create publication supabase_realtime; end if;
|
|
138
|
-
end
|
|
139
|
-
$pub$;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Managed by starci app sync (slot app.db.image); do not edit: run "npx starci app sync --write".
|
|
2
|
+
# The image of the db service of a lite app (ruling lite-official-postgres-image): the Docker Official postgres image, pinned by
|
|
3
|
+
# digest, plus pgvector from the PGDG repository the image already configures. The contrib extensions (pgcrypto, pg_trgm, unaccent,
|
|
4
|
+
# uuid-ossp, btree_gist) ship in the image. The stack's db service, `npm run db:create` and `starci app db drift` all build this file.
|
|
5
|
+
FROM {{dbBaseImage}}
|
|
6
|
+
RUN apt-get update \
|
|
7
|
+
&& apt-get install -y --no-install-recommends {{pgvectorPackage}} \
|
|
8
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
"db:
|
|
1
|
+
"db:image": "docker build --tag {{dbImage}} db",
|
|
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}}",
|
|
2
3
|
"db:start": "docker start {{project}}-dev-db",
|
|
3
4
|
"db:stop": "docker stop {{project}}-dev-db",
|
|
4
5
|
"db:reset": "docker rm -f {{project}}-dev-db && docker volume rm {{project}}-dev-db-data && npm run db:create && npm run db:start",
|
|
@@ -27,9 +27,9 @@ components:
|
|
|
27
27
|
role: service
|
|
28
28
|
stateful: true
|
|
29
29
|
volumes: [db-data]
|
|
30
|
-
image: "
|
|
30
|
+
image: "{{project}}/db:v0.0.0@sha256:0000000000000000000000000000000000000000000000000000000000000000"
|
|
31
31
|
compose: infra/swarm/stack.yaml
|
|
32
|
-
purpose: The app's own PostgreSQL
|
|
32
|
+
purpose: The app's own PostgreSQL, the official postgres image plus pgvector built from db/Dockerfile, migrated by starci app db migrate.
|
|
33
33
|
required: true
|
|
34
34
|
ingress:
|
|
35
35
|
role: ingress
|
|
@@ -203,6 +203,8 @@ environments:
|
|
|
203
203
|
images:
|
|
204
204
|
api:
|
|
205
205
|
app: api
|
|
206
|
+
db:
|
|
207
|
+
app: db
|
|
206
208
|
web:
|
|
207
209
|
app: web
|
|
208
210
|
# Public browser configuration; replace these placeholders before deployment.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Development stack
|
|
2
2
|
|
|
3
|
-
Every development dependency runs in Docker Linux containers. The `db` service (
|
|
3
|
+
Every development dependency runs in Docker Linux containers. The `db` service (the official postgres image plus pgvector, built from `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.
|
|
@@ -83,7 +83,7 @@ services:
|
|
|
83
83
|
condition: on-failure
|
|
84
84
|
delay: 5s
|
|
85
85
|
db:
|
|
86
|
-
image: "
|
|
86
|
+
image: "{{project}}/db:v0.0.0@sha256:0000000000000000000000000000000000000000000000000000000000000000"
|
|
87
87
|
environment:
|
|
88
88
|
POSTGRES_DB: postgres
|
|
89
89
|
POSTGRES_PASSWORD_FILE: /run/secrets/db-password
|
|
@@ -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 (image
|
|
4
|
+
The `db` service (the official postgres image plus pgvector, built from `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
|
|
|
@@ -83,7 +83,7 @@ services:
|
|
|
83
83
|
condition: on-failure
|
|
84
84
|
delay: 5s
|
|
85
85
|
db:
|
|
86
|
-
image: "
|
|
86
|
+
image: "{{project}}/db:v0.0.0@sha256:0000000000000000000000000000000000000000000000000000000000000000"
|
|
87
87
|
environment:
|
|
88
88
|
POSTGRES_DB: postgres
|
|
89
89
|
POSTGRES_PASSWORD_FILE: /run/secrets/db-password
|
|
@@ -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
|
|
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 `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
|
|
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 `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
|
|
@@ -6,4 +6,6 @@ export enum JobsLogEvent {
|
|
|
6
6
|
DeliveryFailed = "jobs.delivery.failed",
|
|
7
7
|
/** A failed delivery could not even be recorded as failed; the lease will expire and the job will be claimed again. */
|
|
8
8
|
FailureNotRecorded = "jobs.delivery.failure-not-recorded",
|
|
9
|
+
/** A scheduler tick failed to fire; the next firing claims its own minute (lite, R335). */
|
|
10
|
+
TickFailed = "jobs.tick.failed",
|
|
9
11
|
}
|