@cosmicdrift/kumiko-framework 0.361.0 → 0.363.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/dist/api/auth-middleware.js +7 -5
- package/dist/api/redis-sse-broker.js +10 -8
- package/dist/api/routes.js +2 -2
- package/dist/api/server.js +3 -0
- package/dist/bun-db/query.d.ts +6 -4
- package/dist/bun-db/query.js +66 -92
- package/dist/crypto/kek-source.js +5 -3
- package/dist/crypto/pii-field-encryption.d.ts +4 -1
- package/dist/crypto/pii-field-encryption.js +2 -2
- package/dist/db/apply-entity-event.js +2 -2
- package/dist/db/cursor.js +2 -1
- package/dist/db/dialect.d.ts +1 -0
- package/dist/db/dialect.js +28 -13
- package/dist/db/entity-table-from-registry.js +1 -1
- package/dist/db/entity-table-meta.js +5 -13
- package/dist/db/event-store-executor-context.js +6 -6
- package/dist/db/event-store-executor-read.js +9 -9
- package/dist/db/migrate-generator.js +5 -3
- package/dist/db/migrate-runner.js +31 -35
- package/dist/db/parent-ref-clause.js +2 -2
- package/dist/db/queries/backfill-pii.js +11 -9
- package/dist/db/queries/es-ops.js +1 -1
- package/dist/db/queries/event-consumer.d.ts +6 -6
- package/dist/db/queries/event-consumer.js +17 -17
- package/dist/db/queries/event-store-admin.js +6 -6
- package/dist/db/queries/event-store-transfer.js +2 -2
- package/dist/db/queries/event-store.d.ts +1 -1
- package/dist/db/queries/event-store.js +2 -2
- package/dist/db/queries/pii-backfill-state.js +2 -2
- package/dist/db/queries/projection-rebuild.d.ts +1 -1
- package/dist/db/queries/raw-sql.js +1 -1
- package/dist/db/queries/test-stack.js +1 -1
- package/dist/db/reference-data.js +3 -5
- package/dist/db/table-builder.js +6 -4
- package/dist/db/table-introspection.d.ts +16 -0
- package/dist/db/table-introspection.js +76 -0
- package/dist/db/tenant-db.d.ts +3 -2
- package/dist/db/tenant-db.js +4 -10
- package/dist/derivatives/variant-key.js +2 -1
- package/dist/engine/boot-validator/access-declarations.js +29 -18
- package/dist/engine/boot-validator/card-meta-overflow.js +5 -2
- package/dist/engine/boot-validator/entity-list-screens.js +1 -1
- package/dist/engine/boot-validator/pii-retention.js +1 -1
- package/dist/engine/boot-validator/screens.js +2 -1
- package/dist/engine/build-app-schema.js +1 -0
- package/dist/engine/changeset-changes.js +3 -3
- package/dist/engine/define-handler.js +3 -6
- package/dist/engine/embedded-derived.js +10 -6
- package/dist/engine/extensions/escape-hatch-usage.js +2 -1
- package/dist/engine/factories.js +5 -6
- package/dist/engine/feature-ast/extractors/events.js +43 -33
- package/dist/engine/feature-ast/extractors/round3.d.ts +2 -2
- package/dist/engine/feature-ast/extractors/round3.js +4 -4
- package/dist/engine/feature-ast/patcher.d.ts +3 -3
- package/dist/engine/feature-ast/patcher.js +3 -3
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +8 -8
- package/dist/engine/feature-ast/pattern-change-schema.js +3 -3
- package/dist/engine/feature-ast/patterns.d.ts +3 -3
- package/dist/engine/feature-ast/render.js +6 -3
- package/dist/engine/feature-changelog.d.ts +2 -1
- package/dist/engine/feature-changelog.js +8 -6
- package/dist/engine/feature-config-events-jobs.d.ts +17 -7
- package/dist/engine/feature-config-events-jobs.js +20 -5
- package/dist/engine/feature-ui-extensions.js +2 -3
- package/dist/engine/index.d.ts +2 -1
- package/dist/engine/index.js +1 -0
- package/dist/engine/ownership.js +7 -20
- package/dist/engine/pattern-library/mixed-schemas.js +0 -1
- package/dist/engine/registry-ingest.d.ts +2 -1
- package/dist/engine/registry-ingest.js +85 -2
- package/dist/engine/registry-validate.d.ts +1 -0
- package/dist/engine/registry-validate.js +38 -8
- package/dist/engine/registry.js +2 -1
- package/dist/engine/schema-builder.js +1 -3
- package/dist/engine/soft-delete-cleanup.js +2 -1
- package/dist/engine/steps/call-feature.js +1 -3
- package/dist/engine/steps/unsafe-projection-upsert.js +2 -13
- package/dist/engine/template-variables.d.ts +29 -0
- package/dist/engine/template-variables.js +77 -0
- package/dist/engine/tier-resolver-extension.js +2 -3
- package/dist/engine/types/index.d.ts +2 -2
- package/dist/engine/validate-projection-allowlist.js +12 -13
- package/dist/engine/where-rule-lint.js +8 -7
- package/dist/errors/reasons.d.ts +1 -0
- package/dist/errors/reasons.js +2 -0
- package/dist/files/bind-referenced-files.write.d.ts +16 -0
- package/dist/files/bind-referenced-files.write.js +109 -0
- package/dist/files/feature.d.ts +1 -0
- package/dist/files/feature.js +2 -0
- package/dist/files/file-routes.d.ts +2 -1
- package/dist/files/file-routes.js +10 -3
- package/dist/files/local-provider.js +2 -1
- package/dist/files/record-write-check.d.ts +1 -0
- package/dist/files/record-write-check.js +9 -9
- package/dist/i18n/index.d.ts +1 -1
- package/dist/i18n/index.js +1 -1
- package/dist/i18n/mail-registry.d.ts +2 -0
- package/dist/i18n/mail-registry.js +7 -0
- package/dist/jobs/job-runner.js +5 -5
- package/dist/pipeline/dispatch-query.js +2 -3
- package/dist/pipeline/dispatch-shared.js +45 -14
- package/dist/pipeline/event-dispatcher-delivery.js +2 -3
- package/dist/pipeline/index.d.ts +1 -0
- package/dist/pipeline/index.js +1 -0
- package/dist/pipeline/member-read-only-transaction.js +1 -1
- package/dist/pipeline/notify-scope.d.ts +13 -0
- package/dist/pipeline/notify-scope.js +32 -0
- package/dist/pipeline/system-hooks.js +2 -1
- package/dist/pipeline/system-identity-switch.js +2 -2
- package/dist/pipeline/write-origin.js +2 -2
- package/dist/search/meilisearch-adapter.js +3 -4
- package/dist/secrets/stored-envelope.js +7 -7
- package/dist/stack/push-entity-projection-tables.js +2 -3
- package/dist/stack/table-helpers.js +10 -26
- package/dist/stack/test-stack.js +2 -5
- package/dist/testing/multipart-helper.d.ts +0 -6
- package/dist/testing/multipart-helper.js +5 -5
- package/dist/testing/without-ambient-temporal.js +4 -4
- package/dist/ui-types/card-column-roles.d.ts +5 -0
- package/dist/ui-types/card-column-roles.js +22 -4
- package/dist/ui-types/index.d.ts +1 -1
- package/dist/ui-types/index.js +1 -1
- package/dist/utils/is-plain-object.d.ts +1 -2
- package/dist/utils/is-plain-object.js +1 -4
- package/package.json +4 -4
- package/src/changes.json +88 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
import { defineWriteHandler, SYSTEM_ROLE, SYSTEM_USER_ID } from "../engine/index.js";
|
|
3
|
+
import { isFileField } from "../engine/types/index.js";
|
|
4
|
+
import { ValidationError } from "../errors/index.js";
|
|
5
|
+
import { fileRefIdsOf } from "./bind-pending-file-refs.js";
|
|
6
|
+
import { getFileRefExecutor } from "./create-file-ref.js";
|
|
7
|
+
import { FILE_REF_ENTITY_NAME } from "./file-ref-entity.js";
|
|
8
|
+
import { findImplicitEntityTable } from "./record-write-check.js";
|
|
9
|
+
const MAX_PAGE_SIZE = 500;
|
|
10
|
+
const DEFAULT_PAGE_SIZE = 200;
|
|
11
|
+
const payloadSchema = z.object({
|
|
12
|
+
entityType: z.string().min(1),
|
|
13
|
+
entityIds: z.array(z.uuid()).min(1).max(MAX_PAGE_SIZE).optional(),
|
|
14
|
+
afterId: z.uuid().optional(),
|
|
15
|
+
limit: z.number().int().min(1).max(MAX_PAGE_SIZE).default(DEFAULT_PAGE_SIZE),
|
|
16
|
+
});
|
|
17
|
+
function invalidEntityType() {
|
|
18
|
+
return new ValidationError({
|
|
19
|
+
fields: [
|
|
20
|
+
{
|
|
21
|
+
path: "entityType",
|
|
22
|
+
code: "invalid_value",
|
|
23
|
+
i18nKey: "errors.validation.invalid_value",
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
// The entity must have an implicit table and declared file fields, and the files
|
|
29
|
+
// feature must be mounted; anything else is a caller error, not an empty page.
|
|
30
|
+
function resolveFileBearingEntity(registry, entityType) {
|
|
31
|
+
const entity = registry.getEntity(entityType);
|
|
32
|
+
const table = findImplicitEntityTable(registry, entityType);
|
|
33
|
+
if (entity === undefined || table === undefined)
|
|
34
|
+
throw invalidEntityType();
|
|
35
|
+
if (registry.getEntity(FILE_REF_ENTITY_NAME) === undefined)
|
|
36
|
+
throw invalidEntityType();
|
|
37
|
+
const fileFieldNames = Object.entries(entity.fields)
|
|
38
|
+
.filter(([, fieldDef]) => isFileField(fieldDef))
|
|
39
|
+
.map(([fieldName]) => fieldName);
|
|
40
|
+
if (fileFieldNames.length === 0)
|
|
41
|
+
throw invalidEntityType();
|
|
42
|
+
return { entity, table, fileFieldNames };
|
|
43
|
+
}
|
|
44
|
+
// Either the named records or one id-ordered page after afterId.
|
|
45
|
+
function loadRecordPage(db, { entity, table }, { entityIds, afterId, limit }) {
|
|
46
|
+
return db.selectMany(table, {
|
|
47
|
+
...(entity.softDelete === true && { isDeleted: false }),
|
|
48
|
+
...(entityIds !== undefined
|
|
49
|
+
? { id: entityIds }
|
|
50
|
+
: afterId !== undefined && { id: { gt: afterId } }),
|
|
51
|
+
}, { orderBy: { col: "id", direction: "asc" }, ...(entityIds === undefined && { limit }) });
|
|
52
|
+
}
|
|
53
|
+
// Unbound, same tenant, uploaded by the record's creator (or the system), and either
|
|
54
|
+
// unclaimed or pending for exactly this entity field.
|
|
55
|
+
function isBindableToRecord(fileRef, target) {
|
|
56
|
+
const uploader = fileRef["insertedById"];
|
|
57
|
+
return (typeof uploader === "string" &&
|
|
58
|
+
(uploader === target.record["insertedById"] || uploader === SYSTEM_USER_ID) &&
|
|
59
|
+
fileRef["tenantId"] === target.tenantId &&
|
|
60
|
+
fileRef["entityId"] === null &&
|
|
61
|
+
(fileRef["entityType"] === null ||
|
|
62
|
+
(fileRef["entityType"] === target.entityType && fileRef["fieldName"] === target.fieldName)));
|
|
63
|
+
}
|
|
64
|
+
// The binding follows only the record's declared file fields; the caller names
|
|
65
|
+
// no file ids. The record's creator must have uploaded the file themselves (or the
|
|
66
|
+
// system generated it), otherwise a foreign reference would pull someone else's
|
|
67
|
+
// private upload into the referencing user's record and make it readable there.
|
|
68
|
+
export const bindReferencedFilesWrite = defineWriteHandler({
|
|
69
|
+
name: "bindReferencedFiles",
|
|
70
|
+
schema: payloadSchema,
|
|
71
|
+
access: { roles: [SYSTEM_ROLE, "SystemAdmin"] },
|
|
72
|
+
// Maintenance backfill run by ops or system jobs, not a task an assistant performs.
|
|
73
|
+
agent: { expose: false },
|
|
74
|
+
description: "Binds the files a record references through its declared file fields to that record, so readers of the record see them through record access. Covers files uploaded before pending-upload binding existed (entityId null) and records created without a save lifecycle (custom handlers, system jobs). Binds only files uploaded by the record's creator or generated by the system; skips files already bound elsewhere, uploaded by someone else, of another tenant or missing; never rebinds.",
|
|
75
|
+
handler: async (event, ctx) => {
|
|
76
|
+
const { entityType, entityIds, limit } = event.payload;
|
|
77
|
+
const { fileFieldNames, ...source } = resolveFileBearingEntity(ctx.registry, entityType);
|
|
78
|
+
const records = await loadRecordPage(ctx.db, source, event.payload);
|
|
79
|
+
const executor = getFileRefExecutor();
|
|
80
|
+
let bound = 0;
|
|
81
|
+
let skipped = 0;
|
|
82
|
+
for (const record of records) {
|
|
83
|
+
for (const fieldName of fileFieldNames) {
|
|
84
|
+
for (const fileRefId of fileRefIdsOf(record[fieldName])) {
|
|
85
|
+
const row = await executor.detail({ id: fileRefId }, event.user, ctx.db);
|
|
86
|
+
if (row === null ||
|
|
87
|
+
!isBindableToRecord(row, {
|
|
88
|
+
record,
|
|
89
|
+
entityType,
|
|
90
|
+
fieldName,
|
|
91
|
+
tenantId: event.user.tenantId,
|
|
92
|
+
})) {
|
|
93
|
+
skipped += 1;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const version = typeof row["version"] === "number" ? row["version"] : undefined;
|
|
97
|
+
const result = await executor.update({ id: fileRefId, version, changes: { entityType, entityId: record.id, fieldName } }, event.user, ctx.db);
|
|
98
|
+
if (!result.isSuccess)
|
|
99
|
+
return result;
|
|
100
|
+
bound += 1;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const lastRecord = records[records.length - 1];
|
|
105
|
+
const pageIsFull = entityIds === undefined && records.length === limit;
|
|
106
|
+
const nextAfterId = pageIsFull ? (lastRecord?.id ?? null) : null;
|
|
107
|
+
return { isSuccess: true, data: { bound, skipped, nextAfterId } };
|
|
108
|
+
},
|
|
109
|
+
});
|
package/dist/files/feature.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ export { fileRefEntity } from "./file-ref-entity.js";
|
|
|
4
4
|
export type FilesFeatureOptions = {
|
|
5
5
|
readonly accessGuard?: FileAccessGuard;
|
|
6
6
|
readonly privilegedRoles?: readonly string[];
|
|
7
|
+
readonly unclaimedUploadReaders?: readonly string[];
|
|
7
8
|
readonly maxUploadSize?: string;
|
|
8
9
|
};
|
|
9
10
|
export declare function createFilesFeature(options?: FilesFeatureOptions): FeatureDefinition;
|
package/dist/files/feature.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { defineFeature } from "../engine/index.js";
|
|
2
|
+
import { bindReferencedFilesWrite } from "./bind-referenced-files.write.js";
|
|
2
3
|
import { FILE_REF_ENTITY_NAME, fileRefEntity } from "./file-ref-entity.js";
|
|
3
4
|
export { fileRefEntity } from "./file-ref-entity.js";
|
|
4
5
|
// files — `fileRef` als ganz normales ES-Entity. Upload/Delete laufen über
|
|
@@ -21,6 +22,7 @@ export function createFilesFeature(options = {}) {
|
|
|
21
22
|
recommended: false,
|
|
22
23
|
});
|
|
23
24
|
r.entity(FILE_REF_ENTITY_NAME, fileRefEntity);
|
|
25
|
+
r.writeHandler(bindReferencedFilesWrite);
|
|
24
26
|
return { routeOptions: options };
|
|
25
27
|
});
|
|
26
28
|
}
|
|
@@ -28,10 +28,11 @@ export type FileRoutesOptions = {
|
|
|
28
28
|
readonly registry?: Registry;
|
|
29
29
|
readonly maxUploadSize?: string;
|
|
30
30
|
readonly privilegedRoles?: readonly string[];
|
|
31
|
+
readonly unclaimedUploadReaders?: readonly string[];
|
|
31
32
|
readonly accessGuard?: FileAccessGuard;
|
|
32
33
|
readonly canReadRecord?: RecordReadCheck;
|
|
33
34
|
readonly canWriteRecord?: RecordWriteCheck;
|
|
34
35
|
};
|
|
35
36
|
export declare function resolveMaxUploadBodyBytes(options: Pick<FileRoutesOptions, "registry" | "maxUploadSize">): number;
|
|
36
|
-
export declare function readFilesRouteOptions(registry: Registry): Pick<FileRoutesOptions, "accessGuard" | "privilegedRoles" | "maxUploadSize">;
|
|
37
|
+
export declare function readFilesRouteOptions(registry: Registry): Pick<FileRoutesOptions, "accessGuard" | "privilegedRoles" | "unclaimedUploadReaders" | "maxUploadSize">;
|
|
37
38
|
export declare function createFileRoutes(options: FileRoutesOptions): Hono;
|
|
@@ -26,12 +26,18 @@ const SIGNED_URL_DEFAULT_EXPIRY_SECONDS = 15 * 60;
|
|
|
26
26
|
// files. Pending uploads (entityType set, record not yet created) are the
|
|
27
27
|
// uploader's own until bound; so are uploads without an entityType, which no
|
|
28
28
|
// record can ever claim.
|
|
29
|
-
function createDefaultGuard(privilegedRoles, canReadRecord) {
|
|
29
|
+
function createDefaultGuard(privilegedRoles, canReadRecord, unclaimedUploadReaders) {
|
|
30
30
|
return async ({ fileRef, user, operation }) => {
|
|
31
31
|
if (fileRef.insertedById === user.id)
|
|
32
32
|
return "allow";
|
|
33
33
|
if (privilegedRoles.some((role) => user.roles.includes(role)))
|
|
34
34
|
return "allow";
|
|
35
|
+
const isUnclaimed = fileRef.entityType === null && fileRef.entityId === null;
|
|
36
|
+
if (isUnclaimed &&
|
|
37
|
+
operation === "read" &&
|
|
38
|
+
unclaimedUploadReaders.some((role) => user.roles.includes(role))) {
|
|
39
|
+
return "allow";
|
|
40
|
+
}
|
|
35
41
|
if (fileRef.entityType === null || fileRef.entityId === null)
|
|
36
42
|
return "deny";
|
|
37
43
|
if (operation !== "read" || canReadRecord === undefined)
|
|
@@ -68,7 +74,7 @@ export function readFilesRouteOptions(registry) {
|
|
|
68
74
|
if (exp && typeof exp === "object" && "routeOptions" in exp) {
|
|
69
75
|
const ro = exp.routeOptions;
|
|
70
76
|
if (ro && typeof ro === "object") {
|
|
71
|
-
// @cast-boundary feature-exports
|
|
77
|
+
// @cast-boundary feature-exports — typeof-checked read-back from untyped feature.exports
|
|
72
78
|
return ro;
|
|
73
79
|
}
|
|
74
80
|
}
|
|
@@ -77,7 +83,8 @@ export function readFilesRouteOptions(registry) {
|
|
|
77
83
|
export function createFileRoutes(options) {
|
|
78
84
|
const { db } = options;
|
|
79
85
|
const privilegedRoles = options.privilegedRoles ?? DEFAULT_PRIVILEGED_ROLES;
|
|
80
|
-
const guard = options.accessGuard ??
|
|
86
|
+
const guard = options.accessGuard ??
|
|
87
|
+
createDefaultGuard(privilegedRoles, options.canReadRecord, options.unclaimedUploadReaders ?? []);
|
|
81
88
|
const executor = getFileRefExecutor();
|
|
82
89
|
const piiSubjectFields = collectPiiSubjectFields(fileRefEntity);
|
|
83
90
|
const api = new Hono();
|
|
@@ -2,6 +2,7 @@ import { createReadStream, createWriteStream } from "node:fs";
|
|
|
2
2
|
import { mkdir, readdir, readFile, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
3
3
|
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
4
4
|
import { pipeline } from "node:stream/promises";
|
|
5
|
+
import { errorCodeOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
5
6
|
import { assertSafeStorageKey } from "./types.js";
|
|
6
7
|
// Local-filesystem backend — intended for dev + tests. Production deploys
|
|
7
8
|
// pick an object-store provider (S3/R2/…). mimeType is ignored here; the
|
|
@@ -112,7 +113,7 @@ export function createLocalProvider(basePath) {
|
|
|
112
113
|
entries = await readdir(walkRoot, { recursive: true, withFileTypes: true });
|
|
113
114
|
}
|
|
114
115
|
catch (err) {
|
|
115
|
-
const code = err
|
|
116
|
+
const code = errorCodeOf(err);
|
|
116
117
|
if (code === "ENOENT" || code === "ENOTDIR")
|
|
117
118
|
return [];
|
|
118
119
|
throw err;
|
|
@@ -14,6 +14,7 @@ export type RecordWriteCheck = (args: {
|
|
|
14
14
|
readonly fieldName: string | undefined;
|
|
15
15
|
readonly user: SessionUser;
|
|
16
16
|
}) => Promise<RecordWriteDecision>;
|
|
17
|
+
export declare function findImplicitEntityTable(registry: Registry, entityType: string): import("@cosmicdrift/kumiko-types/schema-table-types").SchemaTable | undefined;
|
|
17
18
|
export declare function createRecordWriteCheck(args: {
|
|
18
19
|
readonly registry: Registry;
|
|
19
20
|
readonly canReadRecord: RecordReadCheck;
|
|
@@ -8,6 +8,14 @@ export const RecordWriteDecisions = {
|
|
|
8
8
|
forbidden: "forbidden",
|
|
9
9
|
locked: "locked",
|
|
10
10
|
};
|
|
11
|
+
export function findImplicitEntityTable(registry, entityType) {
|
|
12
|
+
for (const projection of registry.getAllProjections().values()) {
|
|
13
|
+
if (projection.isImplicit === true && projection.source === entityType) {
|
|
14
|
+
return projection.table;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
11
19
|
function isEntityUpdateWrite(qn, entityName) {
|
|
12
20
|
return qn.endsWith(`:write:${toKebab(entityName)}:update`);
|
|
13
21
|
}
|
|
@@ -25,20 +33,12 @@ export function createRecordWriteCheck(args) {
|
|
|
25
33
|
}
|
|
26
34
|
return undefined;
|
|
27
35
|
}
|
|
28
|
-
function findEntityTable(entityType) {
|
|
29
|
-
for (const projection of registry.getAllProjections().values()) {
|
|
30
|
-
if (projection.isImplicit === true && projection.source === entityType) {
|
|
31
|
-
return projection.table;
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
return undefined;
|
|
35
|
-
}
|
|
36
36
|
return async ({ entityType, entityId, fieldName, user }) => {
|
|
37
37
|
if (!(await canReadRecord({ entityType, entityId, user }))) {
|
|
38
38
|
return RecordWriteDecisions.notFound;
|
|
39
39
|
}
|
|
40
40
|
const entity = registry.getEntity(entityType);
|
|
41
|
-
const table =
|
|
41
|
+
const table = findImplicitEntityTable(registry, entityType);
|
|
42
42
|
if (!entity || !table)
|
|
43
43
|
return RecordWriteDecisions.notFound;
|
|
44
44
|
// The stored row, not the detail output: the detail query can hide ownership fields.
|
package/dist/i18n/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Registry } from "../engine/types/index.js";
|
|
2
|
-
export { hasMailTranslations, mailT, registerMailTranslations, resolveMailLocale, } from "./mail-registry.js";
|
|
2
|
+
export { hasMailTranslationKey, hasMailTranslations, listMailLocales, mailT, registerMailTranslations, resolveMailLocale, } from "./mail-registry.js";
|
|
3
3
|
export { canonicalizeLocaleTag, DEFAULT_LOCALE, isValidLocaleTag, pickAcceptLanguage, resolveHeaderLocale, } from "./request-locale.js";
|
|
4
4
|
export { SETTINGS_HUB_I18N } from "./settings-hub-keys.js";
|
|
5
5
|
export type I18nOptions = {
|
package/dist/i18n/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { resolveTranslationValue } from "../ui-types/plural.js";
|
|
2
|
-
export { hasMailTranslations, mailT, registerMailTranslations, resolveMailLocale, } from "./mail-registry.js";
|
|
2
|
+
export { hasMailTranslationKey, hasMailTranslations, listMailLocales, mailT, registerMailTranslations, resolveMailLocale, } from "./mail-registry.js";
|
|
3
3
|
export { canonicalizeLocaleTag, DEFAULT_LOCALE, isValidLocaleTag, pickAcceptLanguage, resolveHeaderLocale, } from "./request-locale.js";
|
|
4
4
|
export { SETTINGS_HUB_I18N } from "./settings-hub-keys.js";
|
|
5
5
|
export function createI18n(registry, options) {
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { type TranslationValue } from "../ui-types/plural.js";
|
|
2
2
|
export declare function registerMailTranslations(locale: string, bundle: Readonly<Record<string, TranslationValue>>): void;
|
|
3
|
+
export declare function listMailLocales(): readonly string[];
|
|
4
|
+
export declare function hasMailTranslationKey(locale: string, key: string): boolean;
|
|
3
5
|
export declare function hasMailTranslations(locale: string): boolean;
|
|
4
6
|
export declare function mailT(locale: string, key: string, params?: Readonly<Record<string, string | number>>): string;
|
|
5
7
|
/** Locale key mailT would actually use (exact → root → en). Use for appUrl
|
|
@@ -4,6 +4,13 @@ export function registerMailTranslations(locale, bundle) {
|
|
|
4
4
|
const prev = tables.get(locale) ?? {};
|
|
5
5
|
tables.set(locale, { ...prev, ...bundle });
|
|
6
6
|
}
|
|
7
|
+
export function listMailLocales() {
|
|
8
|
+
return [...tables.keys()];
|
|
9
|
+
}
|
|
10
|
+
// Own key only, without the root/en fallback mailT applies.
|
|
11
|
+
export function hasMailTranslationKey(locale, key) {
|
|
12
|
+
return tables.get(locale)?.[key] !== undefined;
|
|
13
|
+
}
|
|
7
14
|
export function hasMailTranslations(locale) {
|
|
8
15
|
const root = locale.split("-")[0] ?? locale;
|
|
9
16
|
return tables.has(locale) || tables.has(root);
|
package/dist/jobs/job-runner.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { escapeHatchFor } from "@cosmicdrift/kumiko-types/handlers";
|
|
2
|
+
import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
2
3
|
import { Queue, UnrecoverableError, Worker } from "bullmq";
|
|
3
4
|
import { Redis } from "ioredis";
|
|
4
5
|
import { requestContext } from "../api/request-context.js";
|
|
@@ -24,12 +25,11 @@ import { cronRepeatOptions } from "./cron-repeat-options.js";
|
|
|
24
25
|
// run proceeds without it rather than failing over what would be a framework
|
|
25
26
|
// bug, not untrusted input.
|
|
26
27
|
function isJobTriggerEvent(value) {
|
|
27
|
-
if (
|
|
28
|
+
if (!isPlainObject(value))
|
|
28
29
|
return false;
|
|
29
|
-
|
|
30
|
-
if (typeof candidate["id"] !== "string")
|
|
30
|
+
if (typeof value["id"] !== "string")
|
|
31
31
|
return false;
|
|
32
|
-
const headers =
|
|
32
|
+
const headers = value["headers"];
|
|
33
33
|
if (typeof headers !== "object" || headers === null)
|
|
34
34
|
return false;
|
|
35
35
|
return Object.values(headers).every((v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean");
|
|
@@ -834,7 +834,7 @@ export function createJobRunner(options) {
|
|
|
834
834
|
// must resolve for jobs the same way they do for write-handlers, or an
|
|
835
835
|
// app-author job calling ctx.notify(...)/ctx.config(...) hits a TypeError
|
|
836
836
|
// at runtime the write-handler path never would (framework#1532).
|
|
837
|
-
const notify = context._notifyFactory?.(jobSystemUser, tenantId, selfRunner);
|
|
837
|
+
const notify = context._notifyFactory?.(jobSystemUser, tenantId, selfRunner, undefined, context._fileProviderResolver);
|
|
838
838
|
const configDb = context.db; // @cast-boundary db-operator
|
|
839
839
|
// Shared by the config accessor and ctx.derivatives below — both need the
|
|
840
840
|
// same tenant-scoped db, and building it twice would let the two calls
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
1
2
|
import { hasAccess } from "../engine/access.js";
|
|
2
3
|
import { tenantOverrideDenied } from "../engine/cross-tenant.js";
|
|
3
4
|
import { filterReadFields } from "../engine/field-access.js";
|
|
@@ -49,9 +50,7 @@ async function executeQueryInner(ctx, type, payload, user, origin, tx) {
|
|
|
49
50
|
// schema (and custom query schemas that opt in) carries `includeDeleted`,
|
|
50
51
|
// so other handlers never see the flag. Visibility filters still apply
|
|
51
52
|
// downstream (see HandlerContext.includeDeleted) — safe from raw input.
|
|
52
|
-
const includeDeleted =
|
|
53
|
-
parsed.data !== null &&
|
|
54
|
-
parsed.data["includeDeleted"] === true; // @cast-boundary validated-payload
|
|
53
|
+
const includeDeleted = propertyOf(parsed.data, "includeDeleted") === true;
|
|
55
54
|
// A resolved member (ctx.queryAsMember) runs in a Postgres READ ONLY transaction, not just the ctx surface below.
|
|
56
55
|
return isMemberResolutionPrincipal(user)
|
|
57
56
|
? runInMemberReadOnlyTransaction(ctx, tx, (readOnlyTx) => runQueryHandler(ctx, type, handler, parsed.data, includeDeleted, user, origin, readOnlyTx))
|
|
@@ -27,6 +27,7 @@ import { executeQuery } from "./dispatch-query.js";
|
|
|
27
27
|
import { executeWrite } from "./dispatch-write.js";
|
|
28
28
|
import { dispatcherSpanAttributes, isFailedWriteResult, NestedWriteRollback, } from "./dispatcher-utils.js";
|
|
29
29
|
import { createMemberReaderFn } from "./member-reader.js";
|
|
30
|
+
import { createDeferringNotify } from "./notify-scope.js";
|
|
30
31
|
import { createGatedIdentitySwitch, createGatedMemberReader, createGatedProjectionReader, isSystemIdentity, systemIdentitySwitchDenied, } from "./system-identity-switch.js";
|
|
31
32
|
import { buildPersonalDataGate, rootWriteOrigin } from "./write-origin.js";
|
|
32
33
|
// Framework/pipeline stays bundled-features-free, so this can't import the
|
|
@@ -47,8 +48,10 @@ export const DEFAULT_MEMBER_READ_TIMEOUT_MS = 10_000;
|
|
|
47
48
|
// dispatch-Pfade brauchen DbConnection (oder DbTx aus Caller-Scope) für
|
|
48
49
|
// appendEvent/projection-writes; TenantDb-Branch wird hier ausgeschlossen.
|
|
49
50
|
export function resolveDbSource(ctx, tx) {
|
|
50
|
-
|
|
51
|
-
|
|
51
|
+
return tx ?? resolveAppPoolDb(ctx);
|
|
52
|
+
}
|
|
53
|
+
function resolveAppPoolDb(ctx) {
|
|
54
|
+
return ctx.appContext.db; // @cast-boundary db-operator
|
|
52
55
|
}
|
|
53
56
|
// ctx.appendEvent — append a domain event onto a specific aggregate stream
|
|
54
57
|
// in the current tx, then fire matching inline projections. Core logic
|
|
@@ -248,7 +251,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
248
251
|
// that is meant to outlive the request. Computed before `systemDb` below
|
|
249
252
|
// so its guarded escape hatch (ctx.systemDb.outsideTransaction) can wrap
|
|
250
253
|
// this same TenantDb instead of a second, independently-built one.
|
|
251
|
-
const outsideTxSource = ctx.outsideTransactionDb ??
|
|
254
|
+
const outsideTxSource = ctx.outsideTransactionDb ?? resolveAppPoolDb(ctx);
|
|
252
255
|
const rawDbOutsideTransaction = outsideTxSource
|
|
253
256
|
? buildTenantScopedDb(outsideTxSource, undefined)
|
|
254
257
|
: undefined;
|
|
@@ -279,9 +282,40 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
279
282
|
userId: user.id,
|
|
280
283
|
...(reqCtx && { requestId: reqCtx.requestId }),
|
|
281
284
|
});
|
|
282
|
-
|
|
283
|
-
|
|
285
|
+
// Cross-feature bridge: ctx.query/write share the current tx + afterCommitHooks sink.
|
|
286
|
+
// queryAs/writeAs to anyone but the caller itself needs r.systemScope() or { escapeHatch } (system-identity-switch.ts).
|
|
287
|
+
const bridgeSink = afterCommitHooks ?? [];
|
|
288
|
+
// Query bridges and post-commit contexts get a throwaway sink: hooks pushed there never run.
|
|
289
|
+
const hasLiveCommitSink = afterCommitHooks !== undefined && isPostCommit !== true;
|
|
290
|
+
const scheduleAfterCommit = (hook) => {
|
|
291
|
+
bridgeSink.push(hook);
|
|
292
|
+
};
|
|
293
|
+
const notifyFactory = context._notifyFactory;
|
|
294
|
+
const notifyJobDispatcher = isNotifyJobDispatcher(jobRunner) ? jobRunner : undefined;
|
|
295
|
+
// Inside the write tx the app pool's slots can all be held by waiting write txs, so the
|
|
296
|
+
// delivery's own DB work (broadcast lookup, attempt log, channel sends) runs on the outside-tx pool.
|
|
297
|
+
const notifyInTransactionFactory = notifyFactory && tx !== undefined
|
|
298
|
+
? (notifyUser, notifyTenantId) => notifyFactory(notifyUser, notifyTenantId, notifyJobDispatcher, outsideTxSource, context._fileProviderResolver)
|
|
284
299
|
: undefined;
|
|
300
|
+
const notify = buildNotify();
|
|
301
|
+
function buildNotify() {
|
|
302
|
+
if (!notifyFactory)
|
|
303
|
+
return undefined;
|
|
304
|
+
const notifyAfterCommit = notifyFactory(user, user.tenantId, notifyJobDispatcher, undefined, context._fileProviderResolver);
|
|
305
|
+
if (!notifyInTransactionFactory)
|
|
306
|
+
return notifyAfterCommit;
|
|
307
|
+
const notifyInTransaction = notifyInTransactionFactory(user, user.tenantId);
|
|
308
|
+
// A rolled-back write must leave no notification behind.
|
|
309
|
+
if (!hasLiveCommitSink)
|
|
310
|
+
return notifyInTransaction;
|
|
311
|
+
return createDeferringNotify({
|
|
312
|
+
registry,
|
|
313
|
+
tenantId: user.tenantId,
|
|
314
|
+
notifyAfterCommit,
|
|
315
|
+
notifyNow: notifyInTransaction,
|
|
316
|
+
scheduleAfterCommit,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
285
319
|
// Mirror notify: only built when the config feature wired its factory.
|
|
286
320
|
const config = context._configAccessorFactory && db
|
|
287
321
|
? context._configAccessorFactory({
|
|
@@ -361,9 +395,6 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
361
395
|
const metricsFor = meter
|
|
362
396
|
? (targetFeatureName) => createSafeMetricsHandle(meter, targetFeatureName)
|
|
363
397
|
: () => createNoopMetricsHandle();
|
|
364
|
-
// Cross-feature bridge: ctx.query/write share the current tx + afterCommitHooks sink.
|
|
365
|
-
// queryAs/writeAs to anyone but the caller itself needs r.systemScope() or { escapeHatch } (system-identity-switch.ts).
|
|
366
|
-
const bridgeSink = afterCommitHooks ?? [];
|
|
367
398
|
// A failed inner write has already appended its event and projections when a later
|
|
368
399
|
// step (e.g. an inTransaction hook) fails, and the outer handler may swallow the
|
|
369
400
|
// failure result. The savepoint makes the failure undo exactly the inner write; the
|
|
@@ -374,7 +405,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
374
405
|
const innerAfterCommitHooks = [];
|
|
375
406
|
try {
|
|
376
407
|
const result = await runInSavepoint(tx, async (savepointTx) => {
|
|
377
|
-
const innerResult = await executeWrite(ctx, targetType, payload, asUser, origin, savepointTx, // @cast-boundary
|
|
408
|
+
const innerResult = await executeWrite(ctx, targetType, payload, asUser, origin, savepointTx, // @cast-boundary db-runner savepoint handle is a transaction handle
|
|
378
409
|
innerAfterCommitHooks);
|
|
379
410
|
if (isFailedWriteResult(innerResult))
|
|
380
411
|
throw new NestedWriteRollback(innerResult);
|
|
@@ -396,9 +427,6 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
396
427
|
const writeAsUser = async (asUser, targetType, payload) => isPostCommit
|
|
397
428
|
? unwrapSingle(await runBatch(ctx, [{ type: targetType, payload }], asUser, undefined, origin))
|
|
398
429
|
: executeNestedWriteInSavepoint(asUser, targetType, payload);
|
|
399
|
-
const scheduleAfterCommit = (hook) => {
|
|
400
|
-
bridgeSink.push(hook);
|
|
401
|
-
};
|
|
402
430
|
const identitySwitchAudit = { reason: identitySwitchGrantReason, report: reportEscapeHatch };
|
|
403
431
|
const identitySwitch = createGatedIdentitySwitch(`handler "${type}"`, user, hasIdentitySwitchGrant, {
|
|
404
432
|
// Inherits the caller's origin, so switching to SYSTEM cannot shed an anonymous root.
|
|
@@ -733,9 +761,12 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
733
761
|
const locale = reqCtx?.locale ?? safeUserLocale ?? context.defaultLocale ?? DEFAULT_LOCALE;
|
|
734
762
|
// The payload-bucket digest is dispatcher-internal; handlers must not be
|
|
735
763
|
// able to compute bucket keys.
|
|
736
|
-
const { _rateLimitPayloadDigest: _omitPayloadDigest,
|
|
764
|
+
const { _rateLimitPayloadDigest: _omitPayloadDigest,
|
|
765
|
+
// Bound to this call's tx: an outer context's factory must not leak into a tx-less one.
|
|
766
|
+
_notifyInTransactionFactory: _omitOuterNotifyInTransactionFactory, ...contextForHandler } = context;
|
|
737
767
|
const handlerContext = {
|
|
738
768
|
...contextForHandler,
|
|
769
|
+
...(notifyInTransactionFactory && { _notifyInTransactionFactory: notifyInTransactionFactory }),
|
|
739
770
|
registry,
|
|
740
771
|
db: exposedDb,
|
|
741
772
|
dbOutsideTransaction,
|
|
@@ -781,7 +812,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
781
812
|
_userId: user.id,
|
|
782
813
|
_tenantId: user.tenantId,
|
|
783
814
|
_handlerType: type,
|
|
784
|
-
scheduleAfterCommit,
|
|
815
|
+
...(hasLiveCommitSink && { scheduleAfterCommit }),
|
|
785
816
|
...(includeDeleted && { includeDeleted: true }),
|
|
786
817
|
...bridge,
|
|
787
818
|
}; // @cast-boundary engine-bridge
|
|
@@ -57,8 +57,7 @@ export async function acquireConsumerState(tx, name, instanceId, rearmCooldownMs
|
|
|
57
57
|
const cooldownElapsed = Temporal.Instant.compare(state.updatedAt, cooldownDeadline) <= 0;
|
|
58
58
|
if (cooldownElapsed && state.rearmCount < maxRearmCount) {
|
|
59
59
|
const rearmed = await rearmDeadConsumer(tx, name, instanceId);
|
|
60
|
-
const rearmedState = rearmed &&
|
|
61
|
-
coerceRow(rearmed, extractTableInfo(eventConsumerStateTable));
|
|
60
|
+
const rearmedState = rearmed && coerceRow(rearmed, extractTableInfo(eventConsumerStateTable));
|
|
62
61
|
if (rearmedState)
|
|
63
62
|
return { state: rearmedState, skip: null };
|
|
64
63
|
}
|
|
@@ -149,7 +148,7 @@ export async function markProcessing(tx, name, instanceId) {
|
|
|
149
148
|
export async function fetchPendingEvents(tx, cursor, batchSize, pendingRanges = []) {
|
|
150
149
|
const rawRows = await selectPendingAndNewEventRows(tx, cursor, pendingRanges, batchSize);
|
|
151
150
|
const info = extractTableInfo(eventsTable);
|
|
152
|
-
return rawRows.map((row) => coerceRow(row, info));
|
|
151
|
+
return rawRows.map((row) => coerceRow(row, info));
|
|
153
152
|
}
|
|
154
153
|
// Deliver events to the consumer's handler in events.id order, or — when
|
|
155
154
|
// the consumer wires a batchHandler — through it in one call first. Halt-
|
package/dist/pipeline/index.d.ts
CHANGED
|
@@ -30,6 +30,7 @@ export type { LifecycleHooks, SystemHookDef, SystemHooks } from "./lifecycle-pip
|
|
|
30
30
|
export { createLifecycleHooks } from "./lifecycle-pipeline.js";
|
|
31
31
|
export type { MspRebuildDeps } from "./msp-rebuild.js";
|
|
32
32
|
export { rebuildMultiStreamProjection } from "./msp-rebuild.js";
|
|
33
|
+
export { assertNotifyTargetsOwnTenant, createDeferringNotify } from "./notify-scope.js";
|
|
33
34
|
export type { ProjectionProgress, RebuildResult } from "./projection-rebuild.js";
|
|
34
35
|
export { getAllProjectionProgress, getProjectionState, listProjectionsWithState, rebuildProjection, } from "./projection-rebuild.js";
|
|
35
36
|
export type { ProjectionStatus } from "./projection-state.js";
|
package/dist/pipeline/index.js
CHANGED
|
@@ -15,6 +15,7 @@ export { createIdempotencyGuard } from "./idempotency.js";
|
|
|
15
15
|
export { assertInstructionFieldWriteAllowed, assertIrreversibleOperationAllowed, isIrreversibleEntityVerb, } from "./irreversible-operation-gate.js";
|
|
16
16
|
export { createLifecycleHooks } from "./lifecycle-pipeline.js";
|
|
17
17
|
export { rebuildMultiStreamProjection } from "./msp-rebuild.js";
|
|
18
|
+
export { assertNotifyTargetsOwnTenant, createDeferringNotify } from "./notify-scope.js";
|
|
18
19
|
export { getAllProjectionProgress, getProjectionState, listProjectionsWithState, rebuildProjection, } from "./projection-rebuild.js";
|
|
19
20
|
export { createProjectionStateTable, ProjectionStatuses, projectionStateTable, } from "./projection-state.js";
|
|
20
21
|
export { runProjectionsForEvent } from "./projections-runner.js";
|
|
@@ -34,7 +34,7 @@ async function runInDiscardedReadOnlySavepoint(tx, timeoutMs, bindIdleTimeout, f
|
|
|
34
34
|
await runInSavepoint(tx, async (sp) => {
|
|
35
35
|
// kumiko-lint-ignore raw-sql transaction characteristic, no query helper
|
|
36
36
|
await asRawClient(sp).unsafe(memberReadSessionSettingsSql(timeoutMs, bindIdleTimeout));
|
|
37
|
-
// @cast-boundary
|
|
37
|
+
// @cast-boundary db-runner savepoint handle — structurally a DbTx, same shape runInSavepoint's caller relies on
|
|
38
38
|
settled.outcome = { value: await fn(sp) };
|
|
39
39
|
// Always discarded: RELEASE SAVEPOINT would leave the outer tx read-only too.
|
|
40
40
|
throw new SavepointDiscard();
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { TenantId } from "../engine/types/identifiers.js";
|
|
2
|
+
import type { NotifyFn, NotifyOptions, Registry } from "../engine/types/index.js";
|
|
3
|
+
import type { AfterCommitHook } from "./dispatcher-utils.js";
|
|
4
|
+
export declare function assertNotifyTargetsOwnTenant(options: NotifyOptions, tenantId: TenantId): void;
|
|
5
|
+
type DeferringNotifyDeps = {
|
|
6
|
+
readonly registry: Pick<Registry, "getAllNotifications"> | undefined;
|
|
7
|
+
readonly tenantId: TenantId;
|
|
8
|
+
readonly notifyAfterCommit: NotifyFn;
|
|
9
|
+
readonly notifyNow: NotifyFn;
|
|
10
|
+
readonly scheduleAfterCommit: (hook: AfterCommitHook) => void;
|
|
11
|
+
};
|
|
12
|
+
export declare function createDeferringNotify(deps: DeferringNotifyDeps): NotifyFn;
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { assertNotificationVariables } from "../engine/template-variables.js";
|
|
2
|
+
import { AccessDeniedError, FrameworkReasons } from "../errors/index.js";
|
|
3
|
+
function isTenantBroadcast(to) {
|
|
4
|
+
return typeof to === "object" && to !== null && !Array.isArray(to) && "tenant" in to;
|
|
5
|
+
}
|
|
6
|
+
export function assertNotifyTargetsOwnTenant(options, tenantId) {
|
|
7
|
+
const { to } = options;
|
|
8
|
+
if (isTenantBroadcast(to) && to.tenant !== tenantId) {
|
|
9
|
+
throw new AccessDeniedError({
|
|
10
|
+
message: "ctx.notify can only broadcast to the caller's own tenant",
|
|
11
|
+
details: { reason: FrameworkReasons.notifyForeignTenantBroadcast },
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
// Without `immediate`, a notify inside a write transaction waits for the commit so a
|
|
16
|
+
// rolled-back write leaves no notification behind.
|
|
17
|
+
export function createDeferringNotify(deps) {
|
|
18
|
+
const { registry, tenantId, notifyAfterCommit, notifyNow, scheduleAfterCommit } = deps;
|
|
19
|
+
async function notifyDeferringUntilCommit(notificationType, options) {
|
|
20
|
+
assertNotifyTargetsOwnTenant(options, tenantId);
|
|
21
|
+
// Fail the write now: after the commit the problem would only surface in the render job.
|
|
22
|
+
if (registry)
|
|
23
|
+
assertNotificationVariables(registry, notificationType, options.data);
|
|
24
|
+
if (options.immediate === true)
|
|
25
|
+
return notifyNow(notificationType, options);
|
|
26
|
+
scheduleAfterCommit(async () => {
|
|
27
|
+
await notifyAfterCommit(notificationType, options);
|
|
28
|
+
});
|
|
29
|
+
return { kind: "deferred" };
|
|
30
|
+
}
|
|
31
|
+
return notifyDeferringUntilCommit;
|
|
32
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
1
2
|
import { readNonEmptyStringArray, } from "../api/sse-broker.js";
|
|
2
3
|
import { collectSearchableSubjectFields, configuredPiiSubjectKms, decryptPiiFieldValues, isPiiCiphertext, PII_ERASED_SENTINEL, } from "../crypto/index.js";
|
|
3
4
|
import { createTenantDb, entityTableFromRegistry, rehydrateCompoundTypes } from "../db/index.js";
|
|
@@ -520,7 +521,7 @@ const TENANT_MEMBERSHIP_UPDATED_EVENT_TYPE = "tenant-membership.updated";
|
|
|
520
521
|
const TENANT_MEMBERSHIP_DELETED_EVENT_TYPE = "tenant-membership.deleted";
|
|
521
522
|
function readUserIdFromPreviousSnapshot(payload) {
|
|
522
523
|
const previous = payload["previous"];
|
|
523
|
-
if (
|
|
524
|
+
if (!isPlainObject(previous))
|
|
524
525
|
return undefined;
|
|
525
526
|
const userId = previous["userId"];
|
|
526
527
|
return typeof userId === "string" && userId.length > 0 ? userId : undefined;
|
|
@@ -124,14 +124,14 @@ export function createGatedProjectionReader(callerLabel, hasGrant, ungated, audi
|
|
|
124
124
|
function readIdentitySwitchFn(context, key) {
|
|
125
125
|
if (!(key in context))
|
|
126
126
|
return undefined;
|
|
127
|
-
const value = context
|
|
127
|
+
const value = Reflect.get(context, key);
|
|
128
128
|
return typeof value === "function" ? value : undefined; // @cast-boundary engine-bridge — checked via typeof above
|
|
129
129
|
}
|
|
130
130
|
// context's own keys only — never touch a property of the resolved value, which may be a Proxy that throws on any get.
|
|
131
131
|
function readDbLikeValue(context, key) {
|
|
132
132
|
if (!(key in context))
|
|
133
133
|
return undefined;
|
|
134
|
-
const value = context
|
|
134
|
+
const value = Reflect.get(context, key);
|
|
135
135
|
return typeof value === "object" && value !== null ? value : undefined;
|
|
136
136
|
}
|
|
137
137
|
function unavailableIdentitySwitchFn(callerLabel, kind) {
|
|
@@ -39,11 +39,11 @@ function declaresPublicIntake(access) {
|
|
|
39
39
|
// Only the public dispatcher entry points compute a root; every nested call inherits
|
|
40
40
|
// it, so switching identity via ctx.writeAs(SYSTEM, ...) cannot shed an anonymous root.
|
|
41
41
|
export function rootWriteOrigin(registry, type, user) {
|
|
42
|
-
const
|
|
42
|
+
const rootHandler = registry.getWriteHandler(type) ?? registry.getQueryHandler(type);
|
|
43
43
|
return {
|
|
44
44
|
rootHandler: type,
|
|
45
45
|
anonymousRoot: user.roles.includes(ANONYMOUS_ROLE),
|
|
46
|
-
publicIntake:
|
|
46
|
+
publicIntake: rootHandler !== undefined && declaresPublicIntake(rootHandler.access),
|
|
47
47
|
};
|
|
48
48
|
}
|
|
49
49
|
// Owner binding is ignored (honorOwnerBinding=false): all anonymous callers share one
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { errorCodeOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
1
2
|
import { ErrorStatusCode, Meilisearch } from "meilisearch";
|
|
2
3
|
// meilisearch's waitTask() RESOLVES (never rejects) once the server task
|
|
3
4
|
// reaches a terminal state, even "failed" — a rejected doc (e.g. an id over
|
|
@@ -23,10 +24,8 @@ const INDEX_LIST_PAGE_SIZE = 100;
|
|
|
23
24
|
// What an unconfigured Meilisearch index reports as searchableAttributes.
|
|
24
25
|
const MEILI_ALL_ATTRIBUTES = "*";
|
|
25
26
|
function isIndexNotFound(err) {
|
|
26
|
-
const cause = typeof err === "object" && err !== null ? err.cause : null;
|
|
27
|
-
return (
|
|
28
|
-
cause !== null &&
|
|
29
|
-
cause.code === ErrorStatusCode.INDEX_NOT_FOUND);
|
|
27
|
+
const cause = typeof err === "object" && err !== null && "cause" in err ? err.cause : null;
|
|
28
|
+
return errorCodeOf(cause) === ErrorStatusCode.INDEX_NOT_FOUND;
|
|
30
29
|
}
|
|
31
30
|
// Exported for unit tests (index-name / primary-key shape) without a live Meili.
|
|
32
31
|
export function meilisearchTenantIndex(prefix, tenantId) {
|