@cosmicdrift/kumiko-server-runtime 0.406.0 → 0.409.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.
@@ -0,0 +1,34 @@
1
+ import { type DbConnection } from "@cosmicdrift/kumiko-framework/db";
2
+ import type { Registry } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
4
+ import { type SearchAdapter } from "@cosmicdrift/kumiko-framework/search";
5
+ export declare const SKIP_SEARCH_REINDEX_ENV = "KUMIKO_SKIP_SEARCH_REINDEX";
6
+ export declare const SEARCH_DOCUMENT_FORMAT_VERSION = 2;
7
+ export declare const SEARCH_REINDEX_LOCK_KEY = 1936876392;
8
+ type Log = ReturnType<typeof createFallbackLogger>;
9
+ export type SearchReindexOutcome = {
10
+ /** A peer holds the lock and does the work. */
11
+ readonly skippedBecauseLocked: boolean;
12
+ readonly reindexedEntities: readonly string[];
13
+ readonly failedEntities: readonly string[];
14
+ };
15
+ /** Changes when the search fields of the entity or the document format change. */
16
+ export declare function searchReindexFingerprint(registry: Registry, entityName: string): string;
17
+ export declare function runSearchReindexOnBoot(opts: {
18
+ readonly db: DbConnection;
19
+ readonly registry: Registry;
20
+ readonly searchAdapter: SearchAdapter;
21
+ readonly signal?: AbortSignal;
22
+ readonly log?: Log;
23
+ }): Promise<SearchReindexOutcome>;
24
+ type ShutdownRegistry = {
25
+ registerShutdownHook(name: string, fn: () => Promise<void>): void;
26
+ };
27
+ export declare function startSearchReindexOnBoot(opts: {
28
+ readonly db: DbConnection;
29
+ readonly registry: Registry;
30
+ readonly extraContext: object;
31
+ readonly lifecycle: ShutdownRegistry;
32
+ readonly envSource: Readonly<Record<string, string | undefined>>;
33
+ }): void;
34
+ export {};
@@ -0,0 +1,139 @@
1
+ import { createHash } from "node:crypto";
2
+ import { resolveBaseDbAndSearchAdapter } from "@cosmicdrift/kumiko-framework/api";
3
+ import { fetchOne, insertOne, listEventTenantIds, tryWithSessionAdvisoryLock, } from "@cosmicdrift/kumiko-framework/db";
4
+ import { createEsOperationsTable, esOperationsTable } from "@cosmicdrift/kumiko-framework/es-ops";
5
+ import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
6
+ import { reindexEntity } from "@cosmicdrift/kumiko-framework/search";
7
+ export const SKIP_SEARCH_REINDEX_ENV = "KUMIKO_SKIP_SEARCH_REINDEX";
8
+ // Bump when the way documents are built or stored in the index changes (2: long fields are
9
+ // split into pieces), so every searchable entity is indexed again once.
10
+ export const SEARCH_DOCUMENT_FORMAT_VERSION = 2;
11
+ export const SEARCH_REINDEX_LOCK_KEY = 0x73_72_63_68; // 'srch'
12
+ function isAborted(signal) {
13
+ return signal?.aborted === true;
14
+ }
15
+ function searchedEntityNames(registry) {
16
+ return [...registry.getAllEntities().keys()].filter((name) => registry.getSearchableFields(name).length > 0 ||
17
+ registry.getSearchPayloadExtensions(name).length > 0);
18
+ }
19
+ /** Changes when the search fields of the entity or the document format change. */
20
+ export function searchReindexFingerprint(registry, entityName) {
21
+ const fields = [...registry.getSearchableFields(entityName)].sort();
22
+ const extensionCount = registry.getSearchPayloadExtensions(entityName).length;
23
+ return createHash("sha256")
24
+ .update(`${SEARCH_DOCUMENT_FORMAT_VERSION}|${fields.join(",")}|${extensionCount}`)
25
+ .digest("hex")
26
+ .slice(0, 16);
27
+ }
28
+ // The completion marker is a row in kumiko_es_operations, keyed by entity and fingerprint: a
29
+ // changed fingerprint is a missing marker, and the row is only written after a clean run.
30
+ function markerId(entityName, fingerprint) {
31
+ return `search-reindex:${entityName}:${fingerprint}`;
32
+ }
33
+ async function hasMarker(db, id) {
34
+ return (await fetchOne(db, esOperationsTable, { id })) !== undefined;
35
+ }
36
+ async function pendingEntities(db, registry) {
37
+ const pending = [];
38
+ for (const name of searchedEntityNames(registry)) {
39
+ if (!(await hasMarker(db, markerId(name, searchReindexFingerprint(registry, name))))) {
40
+ pending.push(name);
41
+ }
42
+ }
43
+ return pending;
44
+ }
45
+ export async function runSearchReindexOnBoot(opts) {
46
+ const { db, registry, searchAdapter, signal } = opts;
47
+ const log = opts.log ?? createFallbackLogger("search-reindex");
48
+ await createEsOperationsTable(db);
49
+ // Cheap exit for the common boot, without taking the lock.
50
+ if ((await pendingEntities(db, registry)).length === 0) {
51
+ return { skippedBecauseLocked: false, reindexedEntities: [], failedEntities: [] };
52
+ }
53
+ const locked = await tryWithSessionAdvisoryLock(db, SEARCH_REINDEX_LOCK_KEY, async () => {
54
+ // A peer may have finished between the check above and the lock.
55
+ const pending = await pendingEntities(db, registry);
56
+ const tenantIds = pending.length > 0 ? await listEventTenantIds(db) : [];
57
+ const reindexed = [];
58
+ const failed = [];
59
+ for (const entityName of pending) {
60
+ if (isAborted(signal))
61
+ break;
62
+ let clean = true;
63
+ for (const tenantId of tenantIds) {
64
+ if (isAborted(signal)) {
65
+ clean = false;
66
+ break;
67
+ }
68
+ try {
69
+ const result = await reindexEntity(db, registry, searchAdapter, entityName, tenantId);
70
+ if (result.failures.length > 0) {
71
+ clean = false;
72
+ log.error("search reindex had failing rows; it is retried on the next boot", {
73
+ entityName,
74
+ tenantId,
75
+ failures: result.failures.length,
76
+ firstReason: result.failures[0]?.reason,
77
+ });
78
+ }
79
+ }
80
+ catch (e) {
81
+ clean = false;
82
+ log.error("search reindex failed; it is retried on the next boot", {
83
+ entityName,
84
+ tenantId,
85
+ error: e instanceof Error ? e.message : String(e),
86
+ });
87
+ }
88
+ }
89
+ if (!clean) {
90
+ failed.push(entityName);
91
+ continue;
92
+ }
93
+ await insertOne(db, esOperationsTable, {
94
+ id: markerId(entityName, searchReindexFingerprint(registry, entityName)),
95
+ operationType: "search-reindex",
96
+ durationMs: 0,
97
+ appliedBy: "boot",
98
+ });
99
+ reindexed.push(entityName);
100
+ }
101
+ return { reindexed, failed };
102
+ });
103
+ if (!locked.acquired) {
104
+ return { skippedBecauseLocked: true, reindexedEntities: [], failedEntities: [] };
105
+ }
106
+ return {
107
+ skippedBecauseLocked: false,
108
+ reindexedEntities: locked.value.reindexed,
109
+ failedEntities: locked.value.failed,
110
+ };
111
+ }
112
+ // Background like the PII backfill: a reindex scales with the data and must not delay readiness.
113
+ export function startSearchReindexOnBoot(opts) {
114
+ const { searchAdapter } = resolveBaseDbAndSearchAdapter(opts.extraContext, opts.registry);
115
+ // skip: no search backend in this process, or the operator opted out
116
+ if (searchAdapter === undefined || opts.envSource[SKIP_SEARCH_REINDEX_ENV] === "1")
117
+ return;
118
+ const log = createFallbackLogger("search-reindex");
119
+ const abortController = new AbortController();
120
+ const run = runSearchReindexOnBoot({
121
+ db: opts.db,
122
+ registry: opts.registry,
123
+ searchAdapter,
124
+ signal: abortController.signal,
125
+ log,
126
+ }).then((outcome) => {
127
+ if (outcome.reindexedEntities.length > 0) {
128
+ log.info("search reindex finished", { entities: outcome.reindexedEntities });
129
+ }
130
+ }, (e) => {
131
+ log.error("search reindex crashed; it is retried on the next boot", {
132
+ error: e instanceof Error ? e.message : String(e),
133
+ });
134
+ });
135
+ opts.lifecycle.registerShutdownHook("searchReindex", async () => {
136
+ abortController.abort();
137
+ await run;
138
+ });
139
+ }
package/dist/index.d.ts CHANGED
@@ -24,3 +24,4 @@ export { runWorkerApp } from "./run-worker-app.ts";
24
24
  export type { CspExtension, CspOption, SecurityHeadersOption } from "./security-headers.ts";
25
25
  export { buildDefaultCsp } from "./security-headers.ts";
26
26
  export { makeSeedCreateFileRef, type SeedCreateFileRef } from "./seed-dispatcher.ts";
27
+ export { type StaticHtmlPage, writeStaticHtmlPages } from "./static-html-pages.ts";
package/dist/index.js CHANGED
@@ -18,3 +18,4 @@ export { parseRunMode, requireEnv, runProdApp } from "./run-prod-app.js";
18
18
  export { runWorkerApp } from "./run-worker-app.js";
19
19
  export { buildDefaultCsp } from "./security-headers.js";
20
20
  export { makeSeedCreateFileRef } from "./seed-dispatcher.js";
21
+ export { writeStaticHtmlPages } from "./static-html-pages.js";
@@ -11,4 +11,4 @@ export type ProdEntrypointOptions = ReturnType<typeof buildProdEntrypointOptions
11
11
  export declare function createProdEntrypoint(options: RunProdAppOptions, config: ProdBootConfig, composed: ProdComposedFeatures, connections: ProdConnections, entrypointOptions: ProdEntrypointOptions): ProdEntrypoint;
12
12
  export declare function registerProdShutdownHooks(entrypoint: ProdEntrypoint, cacheSync: ProdConnections["cacheSync"], ownedRedisSseBroker: ProdRequestContext["ownedRedisSseBroker"]): void;
13
13
  export declare function runProdWireHook(options: RunProdAppOptions, config: ProdBootConfig, composed: ProdComposedFeatures, connections: ProdConnections, entrypoint: ProdEntrypoint): Promise<void>;
14
- export declare function startProdBackgroundWork(entrypoint: ProdEntrypoint, config: ProdBootConfig, composed: ProdComposedFeatures, connections: ProdConnections): Promise<void>;
14
+ export declare function startProdBackgroundWork(entrypoint: ProdEntrypoint, config: ProdBootConfig, composed: ProdComposedFeatures, connections: ProdConnections, extraContext: object): Promise<void>;
@@ -4,6 +4,7 @@ import { createMetricsForFactory } from "@cosmicdrift/kumiko-framework/observabi
4
4
  import { jobRunLoggerCallbacks } from "./boot/job-run-logger.js";
5
5
  import { startPiiEventBackfillOnBoot } from "./boot/pii-event-backfill-on-boot.js";
6
6
  import { recordAppStartedOnBoot } from "./boot/record-app-started-on-boot.js";
7
+ import { startSearchReindexOnBoot } from "./boot/search-reindex-on-boot.js";
7
8
  import { makeDispatchSystemQuery, makeDispatchSystemWrite } from "./extra-routes-deps.js";
8
9
  import { makeFilesForTenant } from "./files-for-tenant.js";
9
10
  import { buildProdAuthRoutes } from "./run-prod-app-auth.js";
@@ -140,7 +141,7 @@ export async function runProdWireHook(options, config, composed, connections, en
140
141
  });
141
142
  }
142
143
  }
143
- export async function startProdBackgroundWork(entrypoint, config, composed, connections) {
144
+ export async function startProdBackgroundWork(entrypoint, config, composed, connections, extraContext) {
144
145
  const { envSource } = config;
145
146
  await entrypoint.start();
146
147
  await recordAppStartedOnBoot({ db: connections.db, envSource });
@@ -150,4 +151,11 @@ export async function startProdBackgroundWork(entrypoint, config, composed, conn
150
151
  lifecycle: entrypoint.lifecycle,
151
152
  envSource,
152
153
  });
154
+ startSearchReindexOnBoot({
155
+ db: connections.db,
156
+ registry: composed.registry,
157
+ extraContext,
158
+ lifecycle: entrypoint.lifecycle,
159
+ envSource,
160
+ });
153
161
  }
@@ -125,6 +125,8 @@ export function mimeTypeFor(filePath) {
125
125
  return "image/svg+xml";
126
126
  case "png":
127
127
  return "image/png";
128
+ case "webp":
129
+ return "image/webp";
128
130
  case "jpg":
129
131
  case "jpeg":
130
132
  return "image/jpeg";
@@ -142,7 +142,7 @@ async function bootProdApp(options, registerAbortCleanup) {
142
142
  registerProdShutdownHooks(entrypoint, connections.cacheSync, requestContext.ownedRedisSseBroker);
143
143
  await runProdWireHook(options, config, composed, connections, entrypoint);
144
144
  await runProdBootSeeds({ options, config, composed, connections, requestContext }, entrypoint, entrypointOptions.dispatcherOptions);
145
- await startProdBackgroundWork(entrypoint, config, composed, connections);
145
+ await startProdBackgroundWork(entrypoint, config, composed, connections, requestContext.extraContext);
146
146
  const fetch = await buildProdFetchHandler(options, config, entrypoint);
147
147
  // Mark lifecycle ready — health/ready flips to 200 after this.
148
148
  entrypoint.lifecycle.markReady();
@@ -22,6 +22,7 @@ import { logRedisConnectionState } from "@cosmicdrift/kumiko-framework/redis";
22
22
  import { runEnvGate } from "./boot/env-gate.js";
23
23
  import { startPiiEventBackfillOnBoot } from "./boot/pii-event-backfill-on-boot.js";
24
24
  import { recordAppStartedOnBoot } from "./boot/record-app-started-on-boot.js";
25
+ import { startSearchReindexOnBoot } from "./boot/search-reindex-on-boot.js";
25
26
  import { runStreamTenantBackfillOnBoot, } from "./boot/stream-tenant-backfill-on-boot.js";
26
27
  import { bootWorkerProcess, resolveWorkerEnvSource, } from "./boot/worker-boot-core.js";
27
28
  import { startWorkerMetricsServer, } from "./boot/worker-metrics-server.js";
@@ -139,6 +140,13 @@ export async function runWorkerApp(options) {
139
140
  lifecycle: entrypoint.lifecycle,
140
141
  envSource,
141
142
  });
143
+ startSearchReindexOnBoot({
144
+ db,
145
+ registry,
146
+ extraContext: boot.extraContext,
147
+ lifecycle: entrypoint.lifecycle,
148
+ envSource,
149
+ });
142
150
  if (options.wireComponents) {
143
151
  await options.wireComponents({
144
152
  db: wireHookDb(db, options),
@@ -0,0 +1,5 @@
1
+ export type StaticHtmlPage = {
2
+ readonly path: string;
3
+ readonly html: string;
4
+ };
5
+ export declare function writeStaticHtmlPages(outDir: string, pages: readonly StaticHtmlPage[]): Promise<void>;
@@ -0,0 +1,19 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { dirname, isAbsolute, join } from "node:path";
3
+ function assertSafePagePath(path) {
4
+ if (isAbsolute(path) || path.startsWith("/") || path.includes("\\")) {
5
+ throw new Error(`writeStaticHtmlPages: page path "${path}" must be relative and use "/" only`);
6
+ }
7
+ if (path.split("/").includes("..")) {
8
+ throw new Error(`writeStaticHtmlPages: page path "${path}" must not contain ".." segments`);
9
+ }
10
+ }
11
+ export async function writeStaticHtmlPages(outDir, pages) {
12
+ for (const page of pages)
13
+ assertSafePagePath(page.path);
14
+ for (const page of pages) {
15
+ const file = join(outDir, page.path, "index.html");
16
+ await mkdir(dirname(file), { recursive: true });
17
+ await writeFile(file, page.html, "utf-8");
18
+ }
19
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-server-runtime",
3
- "version": "0.406.0",
3
+ "version": "0.409.0",
4
4
  "description": "Production server-boot runtime for Kumiko apps: connections, schema-drift-gate, seeds, lifecycle, graceful shutdown. Symmetric to kumiko-dev-server's runDevApp, without dev/scaffold tooling.",
5
5
  "license": "MIT",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -96,11 +96,11 @@
96
96
  }
97
97
  },
98
98
  "dependencies": {
99
- "@cosmicdrift/kumiko-bundled-features": "0.406.0",
100
- "@cosmicdrift/kumiko-framework": "0.406.0",
101
- "@cosmicdrift/kumiko-renderer": "0.406.0",
102
- "@cosmicdrift/kumiko-renderer-mail-html": "0.406.0",
103
- "@cosmicdrift/kumiko-types": "0.406.0",
99
+ "@cosmicdrift/kumiko-bundled-features": "0.409.0",
100
+ "@cosmicdrift/kumiko-framework": "0.409.0",
101
+ "@cosmicdrift/kumiko-renderer": "0.409.0",
102
+ "@cosmicdrift/kumiko-renderer-mail-html": "0.409.0",
103
+ "@cosmicdrift/kumiko-types": "0.409.0",
104
104
  "ioredis": "^5.11.1",
105
105
  "temporal-polyfill": "^0.3.2",
106
106
  "zod": "^4.6.7"
@@ -109,7 +109,7 @@
109
109
  "bun-types": "^1.4.4"
110
110
  },
111
111
  "devDependencies": {
112
- "@cosmicdrift/kumiko-testing": "0.406.0",
112
+ "@cosmicdrift/kumiko-testing": "0.409.0",
113
113
  "bun-types": "^1.4.4"
114
114
  },
115
115
  "publishConfig": {
package/src/changes.json CHANGED
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "version": "0.409.0",
4
+ "type": "improvement",
5
+ "title": "Boot-time search reindex when search fields or the document format change",
6
+ "detail": "runProdApp and runWorkerApp start a background reindex (reindexEntity for every tenant that owns events) for each searchable entity whose search fingerprint, the sorted search fields plus the document format version 2, has no completion marker yet. The marker is a kumiko_es_operations row written only after a run without failing rows, so an aborted or failed run is retried on the next boot. A Postgres advisory lock lets one process run it while the others skip, and boot readiness does not wait for it. It needs a configured SearchAdapter in the process; KUMIKO_SKIP_SEARCH_REINDEX=1 opts out."
7
+ },
8
+ {
9
+ "version": "0.408.0",
10
+ "type": "improvement",
11
+ "title": "writeStaticHtmlPages and webp content type",
12
+ "detail": "writeStaticHtmlPages(outDir, pages) from @cosmicdrift/kumiko-server-runtime writes each { path, html } to <outDir>/<path>/index.html (an empty path writes <outDir>/index.html) and creates the directories. Before writing anything it rejects absolute paths, backslashes and \"..\" segments. Apps that render marketing pages per locale into dist/pages can use it instead of their own node:fs code. The prod static file server now answers .webp files with content-type image/webp instead of application/octet-stream."
13
+ },
2
14
  {
3
15
  "version": "0.404.0",
4
16
  "type": "improvement",