@cosmicdrift/kumiko-framework 0.383.1 → 0.385.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/agents/examples/custom-fields-basic.md +13 -3
- package/agents/examples/designer-demo.md +6 -2
- package/agents/examples/i18n.md +36 -1
- package/agents/features.md +4 -0
- package/dist/api/auth-rate-limiters.d.ts +8 -0
- package/dist/api/auth-rate-limiters.js +105 -0
- package/dist/api/auth-routes-context.d.ts +34 -0
- package/dist/api/auth-routes-context.js +192 -0
- package/dist/api/auth-routes-invite.d.ts +2 -0
- package/dist/api/auth-routes-invite.js +171 -0
- package/dist/api/auth-routes-login.d.ts +2 -0
- package/dist/api/auth-routes-login.js +103 -0
- package/dist/api/auth-routes-mfa.d.ts +4 -0
- package/dist/api/auth-routes-mfa.js +211 -0
- package/dist/api/auth-routes-session.d.ts +2 -0
- package/dist/api/auth-routes-session.js +144 -0
- package/dist/api/auth-routes-shared.d.ts +33 -0
- package/dist/api/auth-routes-shared.js +81 -0
- package/dist/api/auth-routes-signup.d.ts +2 -0
- package/dist/api/auth-routes-signup.js +75 -0
- package/dist/api/auth-routes-token-flows.d.ts +4 -0
- package/dist/api/auth-routes-token-flows.js +91 -0
- package/dist/api/auth-routes-types.d.ts +76 -0
- package/dist/api/auth-routes-types.js +1 -0
- package/dist/api/auth-routes.d.ts +5 -85
- package/dist/api/auth-routes.js +20 -1095
- package/dist/config/index.d.ts +1 -1
- package/dist/crypto/pii-ciphertext-format.js +1 -1
- package/dist/db/blind-index-cleanup.js +1 -1
- package/dist/db/dialect.js +1 -1
- package/dist/db/distinct-on.d.ts +9 -0
- package/dist/db/distinct-on.js +28 -0
- package/dist/db/event-store-executor-write.js +2 -2
- package/dist/db/migrate-runner.js +4 -4
- package/dist/db/pg-error.js +0 -12
- package/dist/db/queries/event-consumer.js +3 -3
- package/dist/db/queries/postgres-array-literal.js +2 -2
- package/dist/db/query.js +48 -20
- package/dist/db/tenant-db.js +1 -1
- package/dist/engine/boot-validator/i18n-keys.d.ts +2 -0
- package/dist/engine/boot-validator/i18n-keys.js +15 -1
- package/dist/engine/boot-validator/index.d.ts +4 -0
- package/dist/engine/boot-validator/index.js +3 -1
- package/dist/engine/boot-validator/pii-retention.js +6 -7
- package/dist/engine/boot-validator/screens-actions.d.ts +67 -0
- package/dist/engine/boot-validator/screens-actions.js +488 -0
- package/dist/engine/boot-validator/screens-dashboard.d.ts +15 -0
- package/dist/engine/boot-validator/screens-dashboard.js +467 -0
- package/dist/engine/boot-validator/screens-forms.d.ts +24 -0
- package/dist/engine/boot-validator/screens-forms.js +516 -0
- package/dist/engine/boot-validator/screens-lists.d.ts +47 -0
- package/dist/engine/boot-validator/screens-lists.js +319 -0
- package/dist/engine/boot-validator/screens.d.ts +4 -21
- package/dist/engine/boot-validator/screens.js +26 -1799
- package/dist/engine/build-app-schema.js +1 -1
- package/dist/engine/factories.d.ts +3 -3
- package/dist/engine/steps/read-find-one.js +1 -1
- package/dist/engine/types/index.d.ts +1 -1
- package/dist/event-store/provenance-append.js +1 -1
- package/dist/files/file-ref-entity.d.ts +6 -126
- package/dist/files/file-ref-table.d.ts +6 -126
- package/dist/files/file-routes.js +39 -16
- package/dist/i18n/required-surface-keys.d.ts +22 -2
- package/dist/i18n/required-surface-keys.js +91 -3
- package/dist/jobs/job-runner-helpers.d.ts +45 -0
- package/dist/jobs/job-runner-helpers.js +281 -0
- package/dist/jobs/job-runner-schedulers.d.ts +23 -0
- package/dist/jobs/job-runner-schedulers.js +106 -0
- package/dist/jobs/job-runner-types.d.ts +65 -0
- package/dist/jobs/job-runner-types.js +2 -0
- package/dist/jobs/job-runner.d.ts +4 -75
- package/dist/jobs/job-runner.js +7 -384
- package/dist/pipeline/dispatch-shared.js +2 -2
- package/dist/pipeline/event-dispatcher-admin.d.ts +15 -8
- package/dist/pipeline/event-dispatcher-admin.js +28 -7
- package/dist/pipeline/event-dispatcher.d.ts +2 -2
- package/dist/pipeline/event-dispatcher.js +3 -4
- package/dist/pipeline/index.d.ts +2 -2
- package/dist/pipeline/index.js +1 -1
- package/dist/schema-cli.js +5 -0
- package/package.json +3 -3
- package/src/changes.json +39 -0
package/dist/config/index.d.ts
CHANGED
|
@@ -21,7 +21,7 @@ export type AppClientConfig = {
|
|
|
21
21
|
readonly entries: readonly string[];
|
|
22
22
|
};
|
|
23
23
|
/** The boot-validator options an app owns; `env` and the cipher are injected by the runners. */
|
|
24
|
-
export type AppValidateBootOptions = Pick<ValidateBootOptions, "navAllowlist" | "navAllowlistExempt" | "warnOnUniqueAccessRoles">;
|
|
24
|
+
export type AppValidateBootOptions = Pick<ValidateBootOptions, "navAllowlist" | "navAllowlistExempt" | "requiredLocales" | "warnOnUniqueAccessRoles">;
|
|
25
25
|
export type AppConfig = {
|
|
26
26
|
/** Features mounted in every environment: dev, tests, prod. */
|
|
27
27
|
readonly features: readonly FeatureDefinition[];
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Leaf module (no engine imports) so
|
|
1
|
+
// Leaf module (no engine imports) so the query helpers and the executors can sniff PII
|
|
2
2
|
// ciphertext without pulling the KMS/pipeline graph into an import cycle.
|
|
3
3
|
export const PII_CIPHERTEXT_PREFIX_V1 = "kumiko-pii:v1:";
|
|
4
4
|
export const PII_CIPHERTEXT_PREFIX = "kumiko-pii:v2:";
|
|
@@ -77,7 +77,7 @@ async function existingTableNames(db, tableNames) {
|
|
|
77
77
|
export async function subjectRowExistsInTenant(db, features, subjectId, tenantId) {
|
|
78
78
|
// Prefetch which self-PII projection tables actually exist — probing a
|
|
79
79
|
// missing relation used to throw (and get swallowed), which both hid
|
|
80
|
-
// real schema bugs and risked poisoning the
|
|
80
|
+
// real schema bugs and risked poisoning the driver connection for the
|
|
81
81
|
// rest of the forget TX (fw#2348 / framework#356).
|
|
82
82
|
const candidateTables = [];
|
|
83
83
|
for (const feature of features.values()) {
|
package/dist/db/dialect.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Native dialect — replaces drizzle-orm/pg-core re-exports.
|
|
2
2
|
//
|
|
3
3
|
// Produces table objects that simultaneously expose:
|
|
4
|
-
// 1. EntityTableMeta shape (source/columns/indexes) for
|
|
4
|
+
// 1. EntityTableMeta shape (source/columns/indexes) for the query helpers'
|
|
5
5
|
// extractTableInfo + the migrate-runner's renderTableDdl
|
|
6
6
|
// 2. drizzle-compatible Symbol metadata so callers that introspect
|
|
7
7
|
// Symbol.for("kumiko:schema:Name") / Symbol.for("kumiko:schema:Columns") keep
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { OrderByClause } from "@cosmicdrift/kumiko-types/where-clause-types";
|
|
2
|
+
import type { TableInfo } from "./query.ts";
|
|
3
|
+
type DistinctOnClause = {
|
|
4
|
+
/** Physical column names, unquoted. */
|
|
5
|
+
readonly columns: readonly string[];
|
|
6
|
+
readonly orderBy: readonly OrderByClause[];
|
|
7
|
+
};
|
|
8
|
+
export declare function buildDistinctOnClause(info: TableInfo, distinctOn: readonly string[] | undefined, orderBy: OrderByClause | readonly OrderByClause[] | undefined): DistinctOnClause | undefined;
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Postgres rejects DISTINCT ON unless ORDER BY starts with the same
|
|
2
|
+
// expressions, so a mismatch is caught here with a readable message instead of
|
|
3
|
+
// surfacing as a driver error.
|
|
4
|
+
export function buildDistinctOnClause(info, distinctOn, orderBy) {
|
|
5
|
+
if (distinctOn === undefined)
|
|
6
|
+
return undefined;
|
|
7
|
+
if (distinctOn.length === 0) {
|
|
8
|
+
throw new Error(`selectMany: distinctOn must not be empty for "${info.name}"`);
|
|
9
|
+
}
|
|
10
|
+
for (const field of distinctOn) {
|
|
11
|
+
if (!info.hasColumn(field)) {
|
|
12
|
+
throw new Error(`selectMany: distinctOn entry "${field}" is not a column of "${info.name}"`);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
const columns = distinctOn.map((field) => info.columnOf(field));
|
|
16
|
+
if (orderBy === undefined) {
|
|
17
|
+
return { columns, orderBy: distinctOn.map((col) => ({ col })) };
|
|
18
|
+
}
|
|
19
|
+
const clauses = Array.isArray(orderBy) ? orderBy : [orderBy];
|
|
20
|
+
const startsWithDistinctOn = columns.every((column, i) => {
|
|
21
|
+
const clause = clauses[i];
|
|
22
|
+
return (clause !== undefined && info.hasColumn(clause.col) && info.columnOf(clause.col) === column);
|
|
23
|
+
});
|
|
24
|
+
if (!startsWithDistinctOn) {
|
|
25
|
+
throw new Error(`selectMany: orderBy must start with the distinctOn fields [${distinctOn.join(", ")}] in the same order for "${info.name}"`);
|
|
26
|
+
}
|
|
27
|
+
return { columns, orderBy: clauses };
|
|
28
|
+
}
|
|
@@ -290,7 +290,7 @@ export function createWriteVerbs(ctx) {
|
|
|
290
290
|
// selben catch (siehe line 493+).
|
|
291
291
|
let event;
|
|
292
292
|
try {
|
|
293
|
-
// Savepoint-scoped: postgres.js
|
|
293
|
+
// Savepoint-scoped: postgres.js poisons the WHOLE surrounding
|
|
294
294
|
// begin() once any statement inside it errors, even if the JS error
|
|
295
295
|
// is caught (kumiko-framework#1778) — a losing concurrent create's
|
|
296
296
|
// unique-violation would otherwise abort the caller's outer
|
|
@@ -336,7 +336,7 @@ export function createWriteVerbs(ctx) {
|
|
|
336
336
|
// UniqueViolationError 409 damit Designer/Frontend einen sauberen
|
|
337
337
|
// "duplicate" zeigen können statt cryptic "internal server error".
|
|
338
338
|
// Savepoint like the append above: an unclassified DB error out of the
|
|
339
|
-
// projection INSERT would otherwise poison the enclosing tx (
|
|
339
|
+
// projection INSERT would otherwise poison the enclosing tx (postgres-js
|
|
340
340
|
// rejects the whole begin() once any statement in it failed), so a later
|
|
341
341
|
// write sharing that tx — a sibling write after a failed nested one —
|
|
342
342
|
// throws raw as a 500 instead of failing cleanly. The savepoint rolls the
|
|
@@ -188,16 +188,16 @@ function migrationClient(db) {
|
|
|
188
188
|
if (hasBegin(client))
|
|
189
189
|
return client;
|
|
190
190
|
}
|
|
191
|
-
throw new Error("migrate-runner: db argument has no .unsafe() and .begin() (need
|
|
191
|
+
throw new Error("migrate-runner: db argument has no .unsafe() and .begin() (need a postgres-js Sql or drizzle DbConnection)");
|
|
192
192
|
}
|
|
193
|
-
// Raw-SQL via postgres-js
|
|
193
|
+
// Raw-SQL via postgres-js .unsafe().
|
|
194
194
|
async function executeRaw(db, sqlText) {
|
|
195
195
|
await migrationClient(db).unsafe(sqlText);
|
|
196
196
|
}
|
|
197
197
|
async function reserveConnection(db) {
|
|
198
198
|
const client = migrationClient(db);
|
|
199
199
|
if (typeof client.reserve !== "function") {
|
|
200
|
-
throw new Error("migrate-runner: db argument has no .reserve() (need
|
|
200
|
+
throw new Error("migrate-runner: db argument has no .reserve() (need a postgres-js Sql)");
|
|
201
201
|
}
|
|
202
202
|
return client.reserve();
|
|
203
203
|
}
|
|
@@ -235,7 +235,7 @@ export class MigrationChecksumMismatchError extends Error {
|
|
|
235
235
|
// Advisory-Lock verhindert concurrent-apply-races.
|
|
236
236
|
export async function runMigrations(db, migrations) {
|
|
237
237
|
// Session-level advisory lock and per-migration BEGIN/COMMIT must share one
|
|
238
|
-
// session. Pool calls may land on different connections (
|
|
238
|
+
// session. Pool calls may land on different connections (the pool round-robins),
|
|
239
239
|
// which would leave the lock held on a session nobody unlocks.
|
|
240
240
|
const conn = await reserveConnection(db);
|
|
241
241
|
try {
|
package/dist/db/pg-error.js
CHANGED
|
@@ -20,18 +20,6 @@ export function extractPgError(e) {
|
|
|
20
20
|
// @cast-boundary error-details — postgres-js error shape (code, constraint_name)
|
|
21
21
|
const code = layer.code;
|
|
22
22
|
const constraintName = layer.constraint_name; // @cast-boundary error-details
|
|
23
|
-
// Bun.SQL carries the SQLSTATE in `errno` (string) and the constraint name
|
|
24
|
-
// in `constraint`, not in postgres-js's `code`/`constraint_name`.
|
|
25
|
-
if (code === "ERR_POSTGRES_SERVER_ERROR") {
|
|
26
|
-
const errno = layer.errno; // @cast-boundary error-details
|
|
27
|
-
const constraint = layer.constraint; // @cast-boundary error-details
|
|
28
|
-
if (typeof errno === "string") {
|
|
29
|
-
return {
|
|
30
|
-
code: errno,
|
|
31
|
-
constraint_name: constraintName ?? (typeof constraint === "string" ? constraint : undefined),
|
|
32
|
-
};
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
23
|
if (code !== undefined || constraintName !== undefined) {
|
|
36
24
|
return { code, constraint_name: constraintName };
|
|
37
25
|
}
|
|
@@ -178,7 +178,7 @@ export async function updateConsumerDeliveryOutcome(db, name, instanceId, outcom
|
|
|
178
178
|
"status" = $3,
|
|
179
179
|
"last_error" = $4,
|
|
180
180
|
"rearm_count" = CASE WHEN $5 THEN 0 ELSE "rearm_count" END,
|
|
181
|
-
-- text param + cast: a JS string bound straight to ::jsonb double-encodes under
|
|
181
|
+
-- text param + cast: a JS string bound straight to ::jsonb double-encodes under the driver
|
|
182
182
|
"pending_gaps" = $6::text::jsonb,
|
|
183
183
|
"last_failed_event_id" = $9,
|
|
184
184
|
"updated_at" = now()
|
|
@@ -210,7 +210,7 @@ pendingGaps) {
|
|
|
210
210
|
"last_error" = NULL,
|
|
211
211
|
"rearm_count" = 0,
|
|
212
212
|
"last_failed_event_id" = NULL,
|
|
213
|
-
-- text param + cast: a JS string bound straight to ::jsonb double-encodes under
|
|
213
|
+
-- text param + cast: a JS string bound straight to ::jsonb double-encodes under the driver
|
|
214
214
|
"pending_gaps" = $4::text::jsonb,
|
|
215
215
|
"updated_at" = now()
|
|
216
216
|
WHERE "name" = $2 AND "instance_id" = $3
|
|
@@ -239,7 +239,7 @@ export async function updateConsumerRebuildCursor(db, name, instanceId, lastProc
|
|
|
239
239
|
"status" = 'idle',
|
|
240
240
|
"attempts" = 0,
|
|
241
241
|
"last_error" = NULL,
|
|
242
|
-
-- text param + cast: a JS string bound straight to ::jsonb double-encodes under
|
|
242
|
+
-- text param + cast: a JS string bound straight to ::jsonb double-encodes under the driver
|
|
243
243
|
"pending_gaps" = $2::text::jsonb,
|
|
244
244
|
"last_failed_event_id" = NULL,
|
|
245
245
|
"updated_at" = now()
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
1
|
+
// Array params travel as a Postgres array-literal string (`$1::text[]`) so the
|
|
2
|
+
// binding does not depend on how the driver infers JS array types.
|
|
3
3
|
export function toPostgresArrayLiteral(values) {
|
|
4
4
|
const elements = values.map((value) => value === null ? "NULL" : `"${String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`);
|
|
5
5
|
return `{${elements.join(",")}}`;
|
package/dist/db/query.js
CHANGED
|
@@ -24,6 +24,7 @@ import { InternalError } from "../errors/index.js";
|
|
|
24
24
|
import { createFallbackLogger } from "../logging/index.js";
|
|
25
25
|
import { parseJsonSafe } from "../utils/safe-json.js";
|
|
26
26
|
import { SQL_EXPR_BRAND } from "./dialect.js";
|
|
27
|
+
import { buildDistinctOnClause } from "./distinct-on.js";
|
|
27
28
|
import { executorManagedWriteDenied } from "./executor-managed-tables.js";
|
|
28
29
|
import { extractPgError } from "./pg-error.js";
|
|
29
30
|
import { toSnakeCase } from "./table-builder.js";
|
|
@@ -147,7 +148,17 @@ function assertNotTenantScoped(db, fnName) {
|
|
|
147
148
|
"explicit cross-tenant op, or use a scoped TenantDb method.");
|
|
148
149
|
}
|
|
149
150
|
}
|
|
150
|
-
const WHERE_OPERATOR_KEYS = [
|
|
151
|
+
const WHERE_OPERATOR_KEYS = [
|
|
152
|
+
"gt",
|
|
153
|
+
"gte",
|
|
154
|
+
"lt",
|
|
155
|
+
"lte",
|
|
156
|
+
"ne",
|
|
157
|
+
"in",
|
|
158
|
+
"notIn",
|
|
159
|
+
"like",
|
|
160
|
+
"jsonText",
|
|
161
|
+
];
|
|
151
162
|
const _whereOperatorKeysExhaustive = true;
|
|
152
163
|
void _whereOperatorKeysExhaustive;
|
|
153
164
|
function isJsonTextMatchList(value) {
|
|
@@ -664,6 +675,24 @@ function buildWhereClause(info, where, startIndex, qualifier) {
|
|
|
664
675
|
conditions.push(`${ref(col)} IN (${parts.join(", ")})`);
|
|
665
676
|
}
|
|
666
677
|
}
|
|
678
|
+
const notInVal = value.notIn;
|
|
679
|
+
if (Array.isArray(notInVal)) {
|
|
680
|
+
if (pgType === "jsonb") {
|
|
681
|
+
throw new Error(`where: notIn is not supported on jsonb column "${col}"`);
|
|
682
|
+
}
|
|
683
|
+
const parts = [];
|
|
684
|
+
for (const v of notInVal) {
|
|
685
|
+
const p = prepareValue(v, pgType);
|
|
686
|
+
if (p.kind === "literal") {
|
|
687
|
+
parts.push(p.literal);
|
|
688
|
+
}
|
|
689
|
+
else {
|
|
690
|
+
parts.push(`$${idx++}${p.sql}`);
|
|
691
|
+
values.push(p.bound);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
conditions.push(parts.length === 0 ? "TRUE" : `${ref(col)} NOT IN (${parts.join(", ")})`);
|
|
695
|
+
}
|
|
667
696
|
}
|
|
668
697
|
else if (pgType === "jsonb" && isJsonbScalar(value)) {
|
|
669
698
|
conditions.push(jsonbContainsAny(col, [value]));
|
|
@@ -702,7 +731,6 @@ function buildWhereClause(info, where, startIndex, qualifier) {
|
|
|
702
731
|
// Each failed attempt discards one, so retries are bounded by pool size.
|
|
703
732
|
const CLOSED_CONNECTION_CODES = new Set([
|
|
704
733
|
"CONNECTION_CLOSED", // postgres-js
|
|
705
|
-
"ERR_POSTGRES_CONNECTION_CLOSED", // Bun.SQL
|
|
706
734
|
"57P01", // PG SQLSTATE: admin_shutdown / terminated backend
|
|
707
735
|
]);
|
|
708
736
|
export function isClosedConnectionError(err) {
|
|
@@ -728,14 +756,10 @@ function isDriverClosedAbort(err) {
|
|
|
728
756
|
function isHandleLike(raw) {
|
|
729
757
|
return raw !== null && (typeof raw === "object" || typeof raw === "function");
|
|
730
758
|
}
|
|
731
|
-
//
|
|
732
|
-
// handles are plain function objects, so discriminate by members only one driver
|
|
733
|
-
// has: `flush` on Bun.SQL (pool and tx), `typed` on postgres-js (pool and tx).
|
|
759
|
+
// postgres-js pool and tx handles both carry `typed`.
|
|
734
760
|
function driverOf(raw) {
|
|
735
761
|
if (!isHandleLike(raw))
|
|
736
762
|
return "unknown";
|
|
737
|
-
if (typeof raw["flush"] === "function")
|
|
738
|
-
return "bun-sql";
|
|
739
763
|
if (typeof raw["typed"] === "function")
|
|
740
764
|
return "postgres-js";
|
|
741
765
|
return "unknown";
|
|
@@ -743,13 +767,12 @@ function driverOf(raw) {
|
|
|
743
767
|
function positiveNumber(v) {
|
|
744
768
|
return typeof v === "number" && Number.isFinite(v) && v > 0 ? v : undefined;
|
|
745
769
|
}
|
|
746
|
-
// postgres-js configures timeouts in seconds
|
|
747
|
-
function timeoutOptionMs(options, driver,
|
|
748
|
-
if (driver
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
return positiveNumber(options[bunName]);
|
|
770
|
+
// postgres-js configures timeouts in seconds.
|
|
771
|
+
function timeoutOptionMs(options, driver, optionName) {
|
|
772
|
+
if (driver !== "postgres-js")
|
|
773
|
+
return undefined;
|
|
774
|
+
const seconds = positiveNumber(options[optionName]);
|
|
775
|
+
return seconds === undefined ? undefined : seconds * 1000;
|
|
753
776
|
}
|
|
754
777
|
function errorProp(err, name) {
|
|
755
778
|
return isHandleLike(err) ? err[name] : undefined;
|
|
@@ -766,9 +789,9 @@ export function describeClosedConnectionFailure(raw, err, attempts) {
|
|
|
766
789
|
pooled: isPooledClient(raw),
|
|
767
790
|
attempts,
|
|
768
791
|
poolMax: positiveNumber(options["max"]),
|
|
769
|
-
idleTimeoutMs: timeoutOptionMs(options, driver, "
|
|
770
|
-
maxLifetimeMs: timeoutOptionMs(options, driver, "
|
|
771
|
-
connectionTimeoutMs: timeoutOptionMs(options, driver, "
|
|
792
|
+
idleTimeoutMs: timeoutOptionMs(options, driver, "idle_timeout"),
|
|
793
|
+
maxLifetimeMs: timeoutOptionMs(options, driver, "max_lifetime"),
|
|
794
|
+
connectionTimeoutMs: timeoutOptionMs(options, driver, "connect_timeout"),
|
|
772
795
|
errorName: errorProp(err, "name"),
|
|
773
796
|
errorCode: extractPgError(err)?.code ?? errorProp(err, "code"),
|
|
774
797
|
errorErrno: errorProp(err, "errno"),
|
|
@@ -861,15 +884,20 @@ export async function selectMany(db, table, where, options) {
|
|
|
861
884
|
return scoped.selectMany(table, where, options);
|
|
862
885
|
}
|
|
863
886
|
const info = extractTableInfo(table);
|
|
864
|
-
|
|
887
|
+
const distinctOn = buildDistinctOnClause(info, options?.distinctOn, options?.orderBy);
|
|
888
|
+
const distinctPrefix = distinctOn === undefined
|
|
889
|
+
? ""
|
|
890
|
+
: `DISTINCT ON (${distinctOn.columns.map(quoteIdent).join(", ")}) `;
|
|
891
|
+
let sqlText = `SELECT ${distinctPrefix}${selectColumnList(info, options?.fields)} FROM ${quoteIdent(info.name)}`;
|
|
865
892
|
let values = [];
|
|
866
893
|
if (where && Object.keys(where).length > 0) {
|
|
867
894
|
const w = buildWhereClause(info, where, 1);
|
|
868
895
|
sqlText += ` WHERE ${w.sqlText}`;
|
|
869
896
|
values = w.values;
|
|
870
897
|
}
|
|
871
|
-
|
|
872
|
-
|
|
898
|
+
const orderBy = distinctOn?.orderBy ?? options?.orderBy;
|
|
899
|
+
if (orderBy) {
|
|
900
|
+
const clauses = Array.isArray(orderBy) ? orderBy : [orderBy];
|
|
873
901
|
const parts = clauses.map((c) => {
|
|
874
902
|
const col = info.columnOf(c.col);
|
|
875
903
|
const dir = c.direction === "desc" ? "DESC" : "ASC";
|
package/dist/db/tenant-db.js
CHANGED
|
@@ -33,7 +33,7 @@ function boundRunner(runner, gate, projectionRegistry, executorManagedWrites) {
|
|
|
33
33
|
if (typeof value !== "function")
|
|
34
34
|
return value;
|
|
35
35
|
if (prop === "reserve") {
|
|
36
|
-
//
|
|
36
|
+
// postgres-js hands back a raw connection; re-wrap it so the gate survives.
|
|
37
37
|
return async (...args) => {
|
|
38
38
|
const reserved = await Reflect.apply(value, target, args);
|
|
39
39
|
return boundRunner(reserved, gate, projectionRegistry, executorManagedWrites);
|
|
@@ -1,2 +1,4 @@
|
|
|
1
1
|
import type { FeatureDefinition } from "../types/index.ts";
|
|
2
2
|
export declare function validateI18nSurfaceKeys(features: readonly FeatureDefinition[]): void;
|
|
3
|
+
export declare function validateTranslationLocaleConsistency(features: readonly FeatureDefinition[]): void;
|
|
4
|
+
export declare function validateRequiredLocales(features: readonly FeatureDefinition[], requiredLocales: readonly string[] | undefined): void;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { buildEffectiveTranslationKeys, featureHasI18nSurface, requiredKeysFromFeature, requiredKeysFromNav, requiredKeysFromScreen, requiredKeysFromWorkspace, screenTitleKey, } from "../../i18n/required-surface-keys.js";
|
|
1
|
+
import { buildEffectiveTranslationKeys, featureHasI18nSurface, findRequiredLocaleGaps, findTranslationLocaleGaps, requiredKeysFromFeature, requiredKeysFromNav, requiredKeysFromScreen, requiredKeysFromWorkspace, screenTitleKey, } from "../../i18n/required-surface-keys.js";
|
|
2
2
|
import { buildConfigFeatureSchema, SETTINGS_HUB_FEATURE, } from "../build-config-feature-schema.js";
|
|
3
3
|
import { EXTENSION_SELECTOR_HINT_KEY } from "../extension-selector-plugins.js";
|
|
4
4
|
import { createRegistry } from "../registry.js";
|
|
@@ -86,3 +86,17 @@ export function validateI18nSurfaceKeys(features) {
|
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
88
|
}
|
|
89
|
+
export function validateTranslationLocaleConsistency(features) {
|
|
90
|
+
const gaps = findTranslationLocaleGaps(features);
|
|
91
|
+
if (gaps.length > 0) {
|
|
92
|
+
const lines = gaps.map((gap) => `[i18n] Feature "${gap.featureName}": translation key "${gap.key}" is missing locale(s) ${gap.missingLocales.join(", ")}.`);
|
|
93
|
+
throw new Error(`${lines.join("\n")}\nEvery locale a feature uses must be maintained for every one of its translation keys; otherwise move that locale into a locale package.`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
export function validateRequiredLocales(features, requiredLocales) {
|
|
97
|
+
const gaps = findRequiredLocaleGaps(features, requiredLocales ?? []);
|
|
98
|
+
if (gaps.length > 0) {
|
|
99
|
+
const lines = gaps.map((gap) => `[i18n] Feature "${gap.featureName}": translation key "${gap.key}" is missing required locale(s) ${gap.missingLocales.join(", ")}.`);
|
|
100
|
+
throw new Error(`${lines.join("\n")}\nThe app requires locales [${(requiredLocales ?? []).join(", ")}] for every translation key; load the matching locale package or add the missing translations.`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -32,6 +32,10 @@ export type ValidateBootOptions = {
|
|
|
32
32
|
* `masterKey` or the env keyring). Satisfies the availability check for
|
|
33
33
|
* `encrypted: true` fields without an env KEK. */
|
|
34
34
|
readonly entityFieldCipher?: EnvelopeCipher;
|
|
35
|
+
/** Locales every translation key must carry across all mounted features
|
|
36
|
+
* (locale packages count). Without it a key can ship in one language and
|
|
37
|
+
* silently fall back at runtime for users of the others. */
|
|
38
|
+
readonly requiredLocales?: readonly string[];
|
|
35
39
|
};
|
|
36
40
|
/**
|
|
37
41
|
* Subset-safe slice of validateBoot for test stacks: nav references (screen,
|
|
@@ -15,7 +15,7 @@ import { validateEntityListScreens } from "./entity-list-screens.js";
|
|
|
15
15
|
import { validateGdprStoragePersistence } from "./gdpr-storage.js";
|
|
16
16
|
import { validateGlobalTenancyEntities } from "./global-tenancy.js";
|
|
17
17
|
import { validateHandlerFieldReferences } from "./handler-field-references.js";
|
|
18
|
-
import { validateI18nSurfaceKeys } from "./i18n-keys.js";
|
|
18
|
+
import { validateI18nSurfaceKeys, validateRequiredLocales, validateTranslationLocaleConsistency, } from "./i18n-keys.js";
|
|
19
19
|
import { collectKnownRoles, collectNavQns, collectWriteHandlerQns, validateNavCycles, validateNavs, warnOnNavAccessInversion, warnOnUnreachableNavScreens, } from "./nav.js";
|
|
20
20
|
import { collectClaimKeys, validateOwnershipRules } from "./ownership.js";
|
|
21
21
|
import { validateParentRefs } from "./parent-ref.js";
|
|
@@ -218,6 +218,8 @@ export function validateBoot(rawFeatures, options) {
|
|
|
218
218
|
}
|
|
219
219
|
validateDefaultWorkspaceUniqueness(allWorkspaceQns);
|
|
220
220
|
validateI18nSurfaceKeys(features);
|
|
221
|
+
validateTranslationLocaleConsistency(features);
|
|
222
|
+
validateRequiredLocales(features, options?.requiredLocales);
|
|
221
223
|
validateEntityListScreens(features);
|
|
222
224
|
validateSubmitPrefilledHiddenFields(features);
|
|
223
225
|
validateCardMetaOverflow(features);
|
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
import { createFallbackLogger } from "../../logging/utils.js";
|
|
2
1
|
import { PII_DIRECT_NAME_HINTS, PII_USER_OWNED_NAME_HINTS, PII_USER_REFERENCE_NAME_HINTS, } from "./entity-handler.js";
|
|
3
|
-
const log = createFallbackLogger("kumiko:boot");
|
|
4
2
|
// Framework-managed Timestamp-Spalten — dürfen als retention.reference
|
|
5
3
|
// genutzt werden auch wenn nicht in entity.fields deklariert.
|
|
6
4
|
const FRAMEWORK_TIMESTAMP_FIELDS = new Set([
|
|
@@ -146,6 +144,12 @@ export function validatePiiAndRetention(feature) {
|
|
|
146
144
|
if (annotCount > 1) {
|
|
147
145
|
throw new Error(`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has multiple subject-key annotations (personal: "self" / "tenant" / { of: "<field>" }). Pick one — each field belongs to exactly one subject.`);
|
|
148
146
|
}
|
|
147
|
+
if ((field.type === "text" || field.type === "longText") &&
|
|
148
|
+
annotCount === 0 &&
|
|
149
|
+
!annot.allowPlaintext &&
|
|
150
|
+
!annot.subjectRef) {
|
|
151
|
+
throw new Error(`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" (type "${field.type}") declares no personal stance. Build it with createTextField / createLongTextField and { personal: "self" | "tenant" | "ref" | { of: "<ownerField>" } | false }; false additionally needs { reason: "..." } stating why the value is not personal data.`);
|
|
152
|
+
}
|
|
149
153
|
// lookupable (#818): nur text + nur MIT Subject-Annotation. Ohne
|
|
150
154
|
// Subject bleibt der Wert eh Klartext — der Blind-Index wäre eine
|
|
151
155
|
// zweite (deterministische!) Kopie ohne Nutzen.
|
|
@@ -247,11 +251,6 @@ export function validatePiiAndRetention(feature) {
|
|
|
247
251
|
// biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
|
|
248
252
|
console.warn(`[kumiko:boot] [Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has a user-reference-typical name but no personal annotation — a foreign key into \`user\` carries Art.17 obligations even with no annotated content on the entity. Mark it { personal: "ref" } AND register r.useExtension(EXT_USER_DATA, "${entityName}", …) for Art.17 coverage — this warning is the only boot-time check for that, registering the hook is not enforced. Or { personal: { of: "${fieldName}" } } on the field it owns. If business data, set { personal: false, reason: "..." } to silence.`);
|
|
249
253
|
}
|
|
250
|
-
else if (!annot.subjectRef && (field.type === "text" || field.type === "longText")) {
|
|
251
|
-
// The factories refuse a missing stance, but a raw literal such as
|
|
252
|
-
// { type: "text" } still compiles and reaches the entity unchecked.
|
|
253
|
-
log.warn(`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" (type "${field.type}") declares no personal stance. Declare { personal: "self" | "tenant" | "ref" | { of: "<ownerField>" } | false } via createTextField / createLongTextField; "false" additionally needs { reason: "..." } stating why the value is not personal data.`);
|
|
254
|
-
}
|
|
255
254
|
}
|
|
256
255
|
}
|
|
257
256
|
// --- Entity-level retention ---
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { IconKey } from "@cosmicdrift/kumiko-types/nav-icon";
|
|
2
|
+
import type { FeatureDefinition } from "../types/index.ts";
|
|
3
|
+
import type { ActionFormRedirect, ActionFormScreenDefinition, ActionFormSuccessLink, ProjectionDetailScreenDefinition, RelatedListToolbarAction, RowAction, RowActionNavigateBase, RowFieldExtractor, ScreenDefinition, SecretMintScreenDefinition, ToolbarAction } from "../types/screen.ts";
|
|
4
|
+
export declare function isRowClickAction(action: RowAction): boolean;
|
|
5
|
+
export declare function validateAtMostOneRowClick(featureName: string, screenId: string, screenType: "entityList" | "projectionList", rowActions: readonly RowAction[]): void;
|
|
6
|
+
export declare function rowFieldExtractorKeys(params: RowFieldExtractor): readonly string[];
|
|
7
|
+
export declare function readsNavigateParamsAsFormPrefill(target: ScreenDefinition, explicitEntityId: string | undefined, sourceScreenEntity: string | undefined): boolean;
|
|
8
|
+
export type NavigateSourceScreenType = "dashboard" | "entityList" | "projectionList" | "projectionDetail" | "entityEdit" | "actionForm" | "secretMint" | "entityList expandableRow";
|
|
9
|
+
export declare function validateRowActionNavigateParams(featureName: string, screenId: string, screenType: NavigateSourceScreenType, screenEntity: string | undefined, action: RowAction, target: {
|
|
10
|
+
readonly featureName: string;
|
|
11
|
+
readonly screen: ScreenDefinition;
|
|
12
|
+
} | undefined, actionLabel?: "rowAction" | "emptyState action"): void;
|
|
13
|
+
export declare function validateActionHasIcon(featureName: string, screenId: string, screenType: "projectionDetail" | "entityEdit" | "entityList" | "projectionList" | "dashboard", context: string, action: {
|
|
14
|
+
readonly id: string;
|
|
15
|
+
readonly icon?: IconKey;
|
|
16
|
+
}): void;
|
|
17
|
+
export declare function validateActionFieldRefs(featureName: string, screenId: string, actionKind: "rowAction" | "toolbarAction", actionId: string, action: RowAction | ToolbarAction | RelatedListToolbarAction, fieldNames: ReadonlySet<string>, rowMeta: ReadonlySet<string>): void;
|
|
18
|
+
export declare function validateScreenShortIdCollisions(screensByShortId: ReadonlyMap<string, ReadonlyArray<{
|
|
19
|
+
readonly featureName: string;
|
|
20
|
+
readonly screen: ScreenDefinition;
|
|
21
|
+
}>>): void;
|
|
22
|
+
export declare function resolveScreenTargetQn(featureName: string, target: string): string;
|
|
23
|
+
export declare function validateScreenNavTarget(featureName: string, screenId: string, screenKind: string, fieldName: string, value: string, allScreenQns: ReadonlySet<string>, screens: FeatureDefinition["screens"]): void;
|
|
24
|
+
export declare function validateDrawerTargetAction(featureName: string, screenId: string, screenKind: string, actionLabel: "toolbarAction" | "rowAction" | "emptyState action" | "action", action: {
|
|
25
|
+
readonly id: string;
|
|
26
|
+
readonly screen: string;
|
|
27
|
+
readonly params?: RowFieldExtractor;
|
|
28
|
+
}, screens: FeatureDefinition["screens"]): void;
|
|
29
|
+
export declare function isSensitiveOrPasswordField(field: object | undefined): boolean;
|
|
30
|
+
export declare function validateDrawerPrefillKeys(where: string, targetLabel: "drawer-target" | "navigate-target", targetScreenId: string, target: ActionFormScreenDefinition | SecretMintScreenDefinition, params: RowFieldExtractor): void;
|
|
31
|
+
type RowActionNavigateRuntime = RowActionNavigateBase & {
|
|
32
|
+
readonly screen?: string;
|
|
33
|
+
readonly entity?: string;
|
|
34
|
+
};
|
|
35
|
+
export declare function resolveRowActionNavigateTarget(featureName: string, screenId: string, screenType: NavigateSourceScreenType, actionLabel: string, action: RowActionNavigateRuntime, allScreenQns: ReadonlySet<string>, navTargetShortIds: ReadonlySet<string>, screensByShortId: ReadonlyMap<string, ReadonlyArray<{
|
|
36
|
+
readonly featureName: string;
|
|
37
|
+
readonly screen: ScreenDefinition;
|
|
38
|
+
}>>, detailForScreens: ReadonlyMap<string, {
|
|
39
|
+
readonly featureName: string;
|
|
40
|
+
readonly screen: ScreenDefinition;
|
|
41
|
+
}>): {
|
|
42
|
+
readonly featureName: string;
|
|
43
|
+
readonly screen: ScreenDefinition;
|
|
44
|
+
} | undefined;
|
|
45
|
+
export declare function validateRedirectTarget(feature: FeatureDefinition, screenId: string, screenKind: "actionForm" | "secretMint" | "entityEdit" | "projectionDetail", redirect: string | ActionFormRedirect, lookups: NavigateTargetLookups, propertyPath?: string): void;
|
|
46
|
+
export declare function validateIdlessSuccessLink(feature: FeatureDefinition, screenId: string, screenKind: "actionForm", link: ActionFormSuccessLink, lookups: NavigateTargetLookups, propertyPath: string): void;
|
|
47
|
+
export declare function validateClientActionName(featureName: string, screenId: string, screenKind: string, actionLabel: "rowAction" | "emptyState action" | "action", action: Extract<RowAction, {
|
|
48
|
+
readonly kind: "clientAction";
|
|
49
|
+
}>): void;
|
|
50
|
+
export declare function rejectRedirectOnListRowAction(featureName: string, screenId: string, screenKind: string, action: RowAction): void;
|
|
51
|
+
export declare function validateRecordActionRedirect(feature: FeatureDefinition, screenId: string, screenKind: "projectionDetail" | "entityEdit", action: RowAction, lookups: NavigateTargetLookups): void;
|
|
52
|
+
export declare function validatePrimaryActionTarget(featureName: string, screenId: string, screen: ProjectionDetailScreenDefinition): void;
|
|
53
|
+
export declare function validateScreenVisibleWhen(feature: FeatureDefinition, screenId: string, screen: ScreenDefinition, allScreenQns: ReadonlySet<string>, featureMap: ReadonlyMap<string, FeatureDefinition>): void;
|
|
54
|
+
export type NavigateTargetLookups = {
|
|
55
|
+
readonly allScreenQns: ReadonlySet<string>;
|
|
56
|
+
readonly navTargetShortIds: ReadonlySet<string>;
|
|
57
|
+
readonly screensByShortId: ReadonlyMap<string, ReadonlyArray<{
|
|
58
|
+
readonly featureName: string;
|
|
59
|
+
readonly screen: ScreenDefinition;
|
|
60
|
+
}>>;
|
|
61
|
+
readonly detailForScreens: ReadonlyMap<string, {
|
|
62
|
+
readonly featureName: string;
|
|
63
|
+
readonly screen: ScreenDefinition;
|
|
64
|
+
}>;
|
|
65
|
+
};
|
|
66
|
+
export declare function validateNavigateActionTab(featureName: string, screenId: string, screenType: NavigateSourceScreenType, actionLabel: string, action: RowAction, lookups: NavigateTargetLookups): void;
|
|
67
|
+
export {};
|