@cosmicdrift/kumiko-framework 0.150.0 → 0.151.1
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/package.json +2 -2
- package/src/bun-db/index.ts +1 -0
- package/src/db/index.ts +1 -0
- package/src/db/query-api.ts +1 -0
- package/src/engine/__tests__/build-config-feature-schema.test.ts +85 -0
- package/src/engine/__tests__/config-helpers.test.ts +16 -0
- package/src/engine/__tests__/engine.test.ts +41 -0
- package/src/engine/build-config-feature-schema.ts +35 -5
- package/src/engine/config-helpers.ts +2 -0
- package/src/engine/feature-config-events-jobs.ts +50 -34
- package/src/engine/types/config.ts +8 -0
- package/src/engine/types/feature.ts +8 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.151.1",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -193,7 +193,7 @@
|
|
|
193
193
|
"zod": "^4.4.3"
|
|
194
194
|
},
|
|
195
195
|
"devDependencies": {
|
|
196
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
196
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.151.1",
|
|
197
197
|
"bun-types": "^1.3.13",
|
|
198
198
|
"pino-pretty": "^13.1.3"
|
|
199
199
|
},
|
package/src/bun-db/index.ts
CHANGED
package/src/db/index.ts
CHANGED
package/src/db/query-api.ts
CHANGED
|
@@ -351,3 +351,88 @@ describe("buildConfigFeatureSchema — access + workspace", () => {
|
|
|
351
351
|
expect([...roles].sort()).toEqual(["Admin", "SystemAdmin", "TenantAdmin"]);
|
|
352
352
|
});
|
|
353
353
|
});
|
|
354
|
+
|
|
355
|
+
describe("buildConfigFeatureSchema — cross-feature group", () => {
|
|
356
|
+
test("two features sharing a group bucket under one screen/nav per scope, not two", () => {
|
|
357
|
+
const timeTracking = defineFeature("time-tracking", (r) => {
|
|
358
|
+
r.config({
|
|
359
|
+
keys: {
|
|
360
|
+
enabled: createTenantConfig("boolean", {
|
|
361
|
+
group: "bmc-settings",
|
|
362
|
+
mask: { title: "bmc.time-tracking-enabled" },
|
|
363
|
+
}),
|
|
364
|
+
},
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
const autoProtocol = defineFeature("auto-protocol", (r) => {
|
|
368
|
+
r.config({
|
|
369
|
+
keys: {
|
|
370
|
+
enabled: createTenantConfig("boolean", {
|
|
371
|
+
group: "bmc-settings",
|
|
372
|
+
mask: { title: "bmc.auto-protocol-enabled" },
|
|
373
|
+
}),
|
|
374
|
+
},
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
const out = buildConfigFeatureSchema(createRegistry([timeTracking, autoProtocol]));
|
|
379
|
+
expect(out.screens.filter((s) => s.id === "bmc-settings-tenant")).toHaveLength(1);
|
|
380
|
+
expect(out.navs.filter((n) => n.id === "bmc-settings-tenant")).toHaveLength(1);
|
|
381
|
+
|
|
382
|
+
const screen = out.screens.find((s) => s.id === "bmc-settings-tenant");
|
|
383
|
+
if (screen?.type !== "configEdit") throw new Error("expected configEdit screen");
|
|
384
|
+
// Neither feature owns the group name, so field ids are prefixed by the
|
|
385
|
+
// owning feature to stay unique — the whole point of the collision guard.
|
|
386
|
+
expect(screen.configKeys).toEqual({
|
|
387
|
+
"time-tracking-enabled": "time-tracking:config:enabled",
|
|
388
|
+
"auto-protocol-enabled": "auto-protocol:config:enabled",
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
test("same group + colliding owner-prefixed field id throws instead of silently overwriting", () => {
|
|
393
|
+
// "a-b" owner + shortKey "x" and "a" owner + shortKey "b-x" both produce
|
|
394
|
+
// the prefixed field id "a-b-x" — the exact clobbering hazard the
|
|
395
|
+
// owner-prefix scheme must catch, not just avoid in the common case.
|
|
396
|
+
const ab = defineFeature("a-b", (r) => {
|
|
397
|
+
r.config({
|
|
398
|
+
keys: { x: createTenantConfig("boolean", { group: "shared", mask: { title: "ab.x" } }) },
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
const a = defineFeature("a", (r) => {
|
|
402
|
+
r.config({
|
|
403
|
+
keys: {
|
|
404
|
+
bX: createTenantConfig("boolean", { group: "shared", mask: { title: "a.b-x" } }),
|
|
405
|
+
},
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
expect(() => buildConfigFeatureSchema(createRegistry([ab, a]))).toThrow(
|
|
409
|
+
/both resolve to field id "a-b-x"/,
|
|
410
|
+
);
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
test("group unset → behavior unchanged (buckets under the owning feature, plain shortKey)", () => {
|
|
414
|
+
const feature = defineFeature("ungrouped", (r) => {
|
|
415
|
+
r.config({
|
|
416
|
+
keys: { flag: createTenantConfig("boolean", { mask: { title: "u.flag" } }) },
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
const out = buildConfigFeatureSchema(createRegistry([feature]));
|
|
420
|
+
const screen = out.screens.find((s) => s.id === "ungrouped-tenant");
|
|
421
|
+
if (screen?.type !== "configEdit") throw new Error("expected configEdit screen");
|
|
422
|
+
expect(screen.configKeys).toEqual({ flag: "ungrouped:config:flag" });
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
test("invalid (non-kebab) group value throws at schema-build time", () => {
|
|
426
|
+
const feature = defineFeature("badgroup", (r) => {
|
|
427
|
+
r.config({
|
|
428
|
+
keys: {
|
|
429
|
+
flag: createTenantConfig("boolean", {
|
|
430
|
+
group: "Not Kebab!",
|
|
431
|
+
mask: { title: "bg.flag" },
|
|
432
|
+
}),
|
|
433
|
+
},
|
|
434
|
+
});
|
|
435
|
+
});
|
|
436
|
+
expect(() => buildConfigFeatureSchema(createRegistry([feature]))).toThrow(/kebab-case/);
|
|
437
|
+
});
|
|
438
|
+
});
|
|
@@ -257,6 +257,22 @@ describe("config helpers — allowPerRequest opt-in", () => {
|
|
|
257
257
|
});
|
|
258
258
|
});
|
|
259
259
|
|
|
260
|
+
describe("config helpers — group (Settings-Hub namespace override)", () => {
|
|
261
|
+
test("group is carried when set", () => {
|
|
262
|
+
const key = createTenantConfig("boolean", { group: "tenant-settings" });
|
|
263
|
+
expect(key.group).toBe("tenant-settings");
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("no group → field absent (defaults to the owning feature)", () => {
|
|
267
|
+
expect(createTenantConfig("boolean").group).toBeUndefined();
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("group is available on every scope factory", () => {
|
|
271
|
+
expect(createSystemConfig("text", { group: "shared" }).group).toBe("shared");
|
|
272
|
+
expect(createUserConfig("text", { group: "shared" }).group).toBe("shared");
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
260
276
|
describe("config helpers — computed (plan-based / derived values)", () => {
|
|
261
277
|
test("computed function attaches to the definition and returns the typed value", async () => {
|
|
262
278
|
const key = createTenantConfig("number", {
|
|
@@ -1208,6 +1208,47 @@ describe("r.config()", () => {
|
|
|
1208
1208
|
});
|
|
1209
1209
|
});
|
|
1210
1210
|
|
|
1211
|
+
// --- r.configKey() ---
|
|
1212
|
+
|
|
1213
|
+
describe("r.configKey()", () => {
|
|
1214
|
+
test("returns a bare handle with the same qualified name r.config({keys}) would produce", () => {
|
|
1215
|
+
let viaConfigKey!: { readonly name: string; readonly type: "boolean" };
|
|
1216
|
+
let viaConfig!: { readonly defaultVat: { readonly name: string; readonly type: "number" } };
|
|
1217
|
+
defineFeature("invoicing", (r) => {
|
|
1218
|
+
viaConfigKey = r.configKey("enabled", createTenantConfig("boolean", { default: false }));
|
|
1219
|
+
});
|
|
1220
|
+
defineFeature("invoicing2", (r) => {
|
|
1221
|
+
viaConfig = r.config({ keys: { defaultVat: createTenantConfig("number", { default: 19 }) } });
|
|
1222
|
+
});
|
|
1223
|
+
|
|
1224
|
+
expect(viaConfigKey.name).toBe("invoicing:config:enabled");
|
|
1225
|
+
expect(viaConfigKey.type).toBe("boolean");
|
|
1226
|
+
// Structural check: configKey()'s handle is exactly what config({keys:{one}})
|
|
1227
|
+
// would have produced for that single key — same shape, same qualification scheme.
|
|
1228
|
+
expect(viaConfig.defaultVat.name).toBe("invoicing2:config:default-vat");
|
|
1229
|
+
});
|
|
1230
|
+
|
|
1231
|
+
test("registers the key on the feature exactly like r.config would", () => {
|
|
1232
|
+
const feature = defineFeature("shop", (r) => {
|
|
1233
|
+
r.configKey("maxItems", createTenantConfig("number", { default: 10 }));
|
|
1234
|
+
});
|
|
1235
|
+
expect(feature.configKeys["maxItems"]).toBeDefined();
|
|
1236
|
+
expect(feature.configKeys["maxItems"]?.type).toBe("number");
|
|
1237
|
+
expect(feature.configKeys["maxItems"]?.scope).toBe("tenant");
|
|
1238
|
+
|
|
1239
|
+
const registry = createRegistry([feature]);
|
|
1240
|
+
expect(registry.getConfigKey("shop:config:max-items")).toBeDefined();
|
|
1241
|
+
});
|
|
1242
|
+
|
|
1243
|
+
test("camelCase feature + key are kebab-cased in the handle name", () => {
|
|
1244
|
+
let handle!: { readonly name: string };
|
|
1245
|
+
defineFeature("billingCore", (r) => {
|
|
1246
|
+
handle = r.configKey("monthlyTotalCents", createSystemConfig("number", { default: 0 }));
|
|
1247
|
+
});
|
|
1248
|
+
expect(handle.name).toBe("billing-core:config:monthly-total-cents");
|
|
1249
|
+
});
|
|
1250
|
+
});
|
|
1251
|
+
|
|
1211
1252
|
// --- Relations ---
|
|
1212
1253
|
|
|
1213
1254
|
describe("r.relation()", () => {
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
createSelectField,
|
|
24
24
|
createTextField,
|
|
25
25
|
} from "./factories";
|
|
26
|
+
import { isKebabSegment } from "./qualified-name";
|
|
26
27
|
import type { ConfigKeyDefinition } from "./types/config";
|
|
27
28
|
import type { Registry } from "./types/feature";
|
|
28
29
|
import type { FieldDefinition } from "./types/fields";
|
|
@@ -90,7 +91,14 @@ const MACHINE_WRITE_ROLE = "system" as const;
|
|
|
90
91
|
|
|
91
92
|
type MaskedKey = {
|
|
92
93
|
readonly qn: string;
|
|
94
|
+
// Effective group this key is bucketed under for the Settings-Hub —
|
|
95
|
+
// `def.group` if set, else `ownerFeature`. This is what screen/nav
|
|
96
|
+
// generation groups by.
|
|
93
97
|
readonly feature: string;
|
|
98
|
+
// The feature that actually declared this key (always derived from `qn`).
|
|
99
|
+
// Needed to disambiguate field ids when `feature !== ownerFeature` (a
|
|
100
|
+
// cross-feature `group`) and for collision error messages.
|
|
101
|
+
readonly ownerFeature: string;
|
|
94
102
|
readonly shortKey: string;
|
|
95
103
|
readonly def: ConfigKeyDefinition;
|
|
96
104
|
};
|
|
@@ -197,15 +205,30 @@ function buildScreen(
|
|
|
197
205
|
const configKeys: Record<string, string> = {};
|
|
198
206
|
const fields: Record<string, FieldDefinition> = {};
|
|
199
207
|
const fieldLabels: Record<string, string> = {};
|
|
208
|
+
// Field id collapses to the plain shortKey when the key stays in its own
|
|
209
|
+
// feature's group (100% of today's apps — byte-identical output). Only a
|
|
210
|
+
// cross-feature `group` (feature !== ownerFeature) needs the owner prefix,
|
|
211
|
+
// to keep two features sharing a group from clobbering the same shortKey.
|
|
212
|
+
const fieldId = (k: MaskedKey): string =>
|
|
213
|
+
k.feature === k.ownerFeature ? k.shortKey : `${k.ownerFeature}-${k.shortKey}`;
|
|
214
|
+
const seenFieldIds = new Map<string, string>();
|
|
200
215
|
for (const k of keys) {
|
|
201
|
-
|
|
202
|
-
|
|
216
|
+
const id = fieldId(k);
|
|
217
|
+
const prevQn = seenFieldIds.get(id);
|
|
218
|
+
if (prevQn !== undefined) {
|
|
219
|
+
throw new Error(
|
|
220
|
+
`[Settings-Hub] group "${feature}" scope "${scope}": config keys "${prevQn}" and "${k.qn}" both resolve to field id "${id}" — rename one key or its group.`,
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
seenFieldIds.set(id, k.qn);
|
|
224
|
+
configKeys[id] = k.qn;
|
|
225
|
+
fields[id] = deriveField(k.def);
|
|
203
226
|
// mask is the visibility gate, so collectMaskedKeys guarantees it here.
|
|
204
|
-
if (k.def.mask) fieldLabels[
|
|
227
|
+
if (k.def.mask) fieldLabels[id] = k.def.mask.title;
|
|
205
228
|
}
|
|
206
229
|
const section: EditFieldsSection = {
|
|
207
230
|
title: `${feature}.settings`,
|
|
208
|
-
fields: keys.map(
|
|
231
|
+
fields: keys.map(fieldId),
|
|
209
232
|
};
|
|
210
233
|
return {
|
|
211
234
|
id: shortId,
|
|
@@ -242,9 +265,16 @@ function collectMaskedKeys(registry: Registry): MaskedKey[] {
|
|
|
242
265
|
if (def.mask === undefined || def.computed !== undefined) continue;
|
|
243
266
|
const sep = qn.indexOf(":config:");
|
|
244
267
|
if (sep === -1) continue;
|
|
268
|
+
const ownerFeature = qn.slice(0, sep);
|
|
269
|
+
if (def.group !== undefined && !isKebabSegment(def.group)) {
|
|
270
|
+
throw new Error(
|
|
271
|
+
`[Feature ${ownerFeature}] config key "${qn.slice(sep + ":config:".length)}" has invalid group "${def.group}" — must be kebab-case.`,
|
|
272
|
+
);
|
|
273
|
+
}
|
|
245
274
|
out.push({
|
|
246
275
|
qn,
|
|
247
|
-
feature:
|
|
276
|
+
feature: def.group ?? ownerFeature,
|
|
277
|
+
ownerFeature,
|
|
248
278
|
shortKey: qn.slice(sep + ":config:".length),
|
|
249
279
|
def,
|
|
250
280
|
});
|
|
@@ -95,6 +95,7 @@ type ConfigKeyOptions<T extends ConfigKeyType> = {
|
|
|
95
95
|
inheritedToTenant?: boolean;
|
|
96
96
|
backing?: ConfigBacking;
|
|
97
97
|
mask?: ConfigMask;
|
|
98
|
+
group?: string;
|
|
98
99
|
};
|
|
99
100
|
|
|
100
101
|
// --- Scope Defaults ---
|
|
@@ -132,6 +133,7 @@ function createConfigKey<T extends ConfigKeyType>(
|
|
|
132
133
|
...(opts.inheritedToTenant === false ? { inheritedToTenant: false } : {}),
|
|
133
134
|
...(opts.backing === "secrets" ? { backing: "secrets" } : {}),
|
|
134
135
|
...(opts.mask ? { mask: opts.mask } : {}),
|
|
136
|
+
...(opts.group ? { group: opts.group } : {}),
|
|
135
137
|
};
|
|
136
138
|
}
|
|
137
139
|
|
|
@@ -32,41 +32,57 @@ export function buildConfigEventsJobsMethods<TName extends string>(
|
|
|
32
32
|
state: FeatureBuilderState,
|
|
33
33
|
name: TName,
|
|
34
34
|
) {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
35
|
+
// Hoisted out of the returned object literal (not just a method) so
|
|
36
|
+
// `configKey()` below can call it directly — object-literal methods
|
|
37
|
+
// aren't in scope for their siblings without a `this`-bind.
|
|
38
|
+
function config<
|
|
39
|
+
TKeys extends Readonly<Record<string, ConfigKeyDefinition<ConfigKeyType>>>,
|
|
40
|
+
>(definition: {
|
|
41
|
+
readonly keys: TKeys;
|
|
42
|
+
readonly seeds?: Readonly<Record<string, ConfigSeedDef>>;
|
|
43
|
+
}): { readonly [K in keyof TKeys]: ConfigKeyHandle<TKeys[K]["type"]> } {
|
|
44
|
+
// Qualify eagerly (same as defineEvent) so the handle name matches what
|
|
45
|
+
// the registry stores — lazy qualification would break compile-time
|
|
46
|
+
// autocomplete and hand-built test registries.
|
|
47
|
+
const handles: Record<string, ConfigKeyHandle<ConfigKeyType>> = {};
|
|
48
|
+
for (const [key, keyDef] of Object.entries(definition.keys)) {
|
|
49
|
+
state.configKeys[key] = keyDef;
|
|
50
|
+
handles[key] = {
|
|
51
|
+
name: qn(toKebab(name), "config", toKebab(key)),
|
|
52
|
+
type: keyDef.type,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
// Parse seeds: resolve qualified key names and validate scope
|
|
56
|
+
if (definition.seeds) {
|
|
57
|
+
for (const [shortKey, seedDef] of Object.entries(definition.seeds)) {
|
|
58
|
+
const keyDef = definition.keys[shortKey];
|
|
59
|
+
if (!keyDef) continue; // skip — boot-validator reports unknown keys
|
|
60
|
+
const qualifiedKey = qn(toKebab(name), "config", toKebab(shortKey));
|
|
61
|
+
const scope = seedDef.scope ?? keyDef.scope;
|
|
62
|
+
state.configSeeds.push({
|
|
63
|
+
key: qualifiedKey,
|
|
64
|
+
value: seedDef.value,
|
|
65
|
+
scope,
|
|
66
|
+
tenantId: seedDef.tenantId,
|
|
67
|
+
userId: seedDef.userId,
|
|
68
|
+
});
|
|
66
69
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
+
}
|
|
71
|
+
return handles as {
|
|
72
|
+
readonly [K in keyof TKeys]: ConfigKeyHandle<TKeys[K]["type"]>;
|
|
73
|
+
}; // @cast-boundary engine-bridge — Mapped-Type-Inference at config()-callsite
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
config,
|
|
78
|
+
// Shorthand for a single key — same handle shape `r.config({keys:{name:def}})`
|
|
79
|
+
// would produce for that key, just without the wrapping record. No seeds
|
|
80
|
+
// param: callers needing seeds use `r.config` directly.
|
|
81
|
+
configKey<T extends ConfigKeyType>(
|
|
82
|
+
keyName: string,
|
|
83
|
+
def: ConfigKeyDefinition<T>,
|
|
84
|
+
): ConfigKeyHandle<T> {
|
|
85
|
+
return config({ keys: { [keyName]: def } })[keyName] as ConfigKeyHandle<T>; // @cast-boundary engine-bridge — mapped-type narrows to the single key
|
|
70
86
|
},
|
|
71
87
|
job(
|
|
72
88
|
jobName: string,
|
|
@@ -136,6 +136,14 @@ export type ConfigKeyDefinition<T extends ConfigKeyType = ConfigKeyType> = {
|
|
|
136
136
|
// manuelles r.screen/r.nav). Fehlt `mask`, gilt der Key als internes
|
|
137
137
|
// Plumbing (ENV-provisioniert/computed) und erscheint NICHT im Hub.
|
|
138
138
|
readonly mask?: ConfigMask;
|
|
139
|
+
// Überschreibt, unter welchem Settings-Hub-Namespace/Screen dieser Key
|
|
140
|
+
// gruppiert wird (Default: das deklarierende Feature). Erlaubt einem
|
|
141
|
+
// Feature, seine Keys unter einem fremden oder geteilten Namespace zu
|
|
142
|
+
// bündeln (z.B. viele flache Migrations-Flags unter "tenant-settings"),
|
|
143
|
+
// ohne dass das Ziel-Feature sie kennt. Rührt NICHT an qualifiziertem
|
|
144
|
+
// Namen, Storage, Seeds oder App-Overrides — nur reine UI-Gruppierung.
|
|
145
|
+
// Muss kebab-case sein (Boot-Validierung).
|
|
146
|
+
readonly group?: string;
|
|
139
147
|
};
|
|
140
148
|
|
|
141
149
|
// Label-Träger für den Settings-Hub. `title` ist ein i18n-Key (kein Literal —
|
|
@@ -508,6 +508,14 @@ export type FeatureRegistrar<TFeature extends string = string> = {
|
|
|
508
508
|
readonly seeds?: Readonly<Record<string, ConfigSeedDef>>;
|
|
509
509
|
}): { readonly [K in keyof TKeys]: ConfigKeyHandle<TKeys[K]["type"]> };
|
|
510
510
|
|
|
511
|
+
// Shorthand for a single key — same handle shape `config({keys:{name:def}})`
|
|
512
|
+
// would produce for that key, no wrapping record. No seeds param: callers
|
|
513
|
+
// needing seeds use `config` directly.
|
|
514
|
+
configKey<T extends ConfigKeyType>(
|
|
515
|
+
keyName: string,
|
|
516
|
+
def: ConfigKeyDefinition<T>,
|
|
517
|
+
): ConfigKeyHandle<T>;
|
|
518
|
+
|
|
511
519
|
job(name: string, options: Omit<JobDefinition, "name" | "handler">, handler: JobHandlerFn): void;
|
|
512
520
|
|
|
513
521
|
notification(
|