@cosmicdrift/kumiko-framework 0.304.0 → 0.306.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/package.json +4 -4
- package/src/api/__tests__/extra-route-rejection.test.ts +38 -0
- package/src/api/__tests__/extra-routes.integration.test.ts +30 -0
- package/src/api/__tests__/server-boot-guards.test.ts +1 -0
- package/src/api/__tests__/server-error-logging.test.ts +104 -0
- package/src/api/api-constants.ts +13 -0
- package/src/api/extra-route.ts +33 -4
- package/src/api/index.ts +1 -0
- package/src/api/request-context.ts +5 -4
- package/src/api/routes.ts +26 -1
- package/src/api/server.ts +8 -2
- package/src/bun-db/__tests__/closed-connection-retry.integration.test.ts +159 -0
- package/src/bun-db/__tests__/select-many-retry.integration.test.ts +138 -0
- package/src/bun-db/query.ts +42 -18
- package/src/changes.json +108 -0
- package/src/db/__tests__/pg-error.test.ts +14 -0
- package/src/db/__tests__/system-db-view-export.test.ts +107 -0
- package/src/db/__tests__/tenant-db-no-raw.test.ts +10 -0
- package/src/db/__tests__/with-systemdb-unsafe-raw-grant.test.ts +71 -0
- package/src/db/event-store-executor-write.ts +7 -0
- package/src/db/index.ts +1 -1
- package/src/db/pg-error.ts +13 -0
- package/src/db/queries/__tests__/{unsafe-read-retrying.test.ts → unsafe-read-retrying.integration.test.ts} +28 -28
- package/src/db/tenant-db.ts +140 -16
- package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +6 -1
- package/src/engine/__tests__/boot-validator-s0-integration.test.ts +2 -2
- package/src/engine/__tests__/boot-validator.test.ts +1 -1
- package/src/engine/__tests__/tier-resolver-extension.test.ts +1 -1
- package/src/engine/boot-validator/access-declarations.ts +5 -66
- package/src/engine/extension-names.ts +55 -25
- package/src/engine/extensions/storage-provider.ts +14 -41
- package/src/engine/extensions/tenant-data.ts +4 -0
- package/src/engine/extensions/tenant-resource.ts +40 -0
- package/src/engine/extensions/user-data.ts +8 -7
- package/src/engine/feature-ast/__tests__/handler-header-roundtrip.test.ts +669 -0
- package/src/engine/feature-ast/__tests__/parse.test.ts +5 -3
- package/src/engine/feature-ast/__tests__/patch-update.test.ts +718 -0
- package/src/engine/feature-ast/__tests__/pattern-change-schema.test.ts +819 -0
- package/src/engine/feature-ast/entity-field-types.ts +41 -0
- package/src/engine/feature-ast/extractors/handlers.ts +217 -84
- package/src/engine/feature-ast/extractors/hooks.ts +72 -15
- package/src/engine/feature-ast/extractors/round2.ts +21 -0
- package/src/engine/feature-ast/extractors/shared.ts +9 -0
- package/src/engine/feature-ast/index.ts +11 -1
- package/src/engine/feature-ast/patch.ts +338 -5
- package/src/engine/feature-ast/patcher.ts +2 -2
- package/src/engine/feature-ast/pattern-change-schema.ts +1411 -0
- package/src/engine/feature-ast/patterns.ts +22 -15
- package/src/engine/feature-ast/render.ts +1 -0
- package/src/engine/feature-ui-extensions.ts +8 -7
- package/src/engine/index.ts +23 -5
- package/src/engine/personal-data-fields.ts +66 -0
- package/src/engine/registry-validate.ts +15 -0
- package/src/engine/registry.ts +2 -0
- package/src/engine/types/extension-options-map.ts +1 -0
- package/src/engine/types/index.ts +8 -0
- package/src/env/__tests__/dry-run.test.ts +43 -3
- package/src/env/dry-run.ts +28 -15
- package/src/errors/__tests__/write-failures.test.ts +47 -4
- package/src/errors/i18n/de.yaml +12 -0
- package/src/errors/i18n/en.yaml +12 -0
- package/src/errors/reasons.ts +4 -0
- package/src/errors/write-error-info.ts +12 -3
- package/src/jobs/__tests__/job-backoff.integration.test.ts +155 -0
- package/src/jobs/__tests__/job-retention.integration.test.ts +316 -0
- package/src/jobs/__tests__/job-retry-enqueue-paths.integration.test.ts +309 -0
- package/src/jobs/__tests__/jobs.integration.test.ts +38 -3
- package/src/jobs/job-runner.ts +170 -19
- package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +113 -26
- package/src/pipeline/__tests__/dispatcher.test.ts +5 -0
- package/src/pipeline/__tests__/hook-systemdb-escape-hatch.integration.test.ts +246 -0
- package/src/pipeline/__tests__/idempotency-transient-failure.integration.test.ts +198 -0
- package/src/pipeline/__tests__/public-intake-runtime-gate.integration.test.ts +425 -0
- package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +59 -0
- package/src/pipeline/active-membership.ts +5 -1
- package/src/pipeline/dispatch-batch.ts +59 -13
- package/src/pipeline/dispatch-query.ts +16 -5
- package/src/pipeline/dispatch-shared.ts +12 -5
- package/src/pipeline/dispatch-stream.ts +7 -2
- package/src/pipeline/dispatch-write.ts +22 -5
- package/src/pipeline/dispatcher.ts +9 -2
- package/src/pipeline/idempotency.ts +16 -0
- package/src/pipeline/member-reader.ts +3 -1
- package/src/pipeline/system-identity-switch.ts +22 -4
- package/src/pipeline/write-origin.ts +107 -0
- package/src/rate-limit/__tests__/middleware.integration.test.ts +40 -0
- package/src/rate-limit/middleware.ts +3 -0
- package/src/stack/__tests__/setup-test-stack-metrics.integration.test.ts +79 -0
- package/src/stack/test-stack.ts +5 -0
- package/src/testing/closed-connection-error.ts +62 -0
- package/src/testing/index.ts +1 -0
- package/src/bun-db/__tests__/select-many-retry.test.ts +0 -79
|
@@ -13,13 +13,21 @@ export type {
|
|
|
13
13
|
export { getPatternSchema, groupByCategory, PATTERN_LIBRARY } from "../pattern-library";
|
|
14
14
|
export type { ParseError, ParseResult } from "./parse";
|
|
15
15
|
export { parseFeatureFile, parseSourceFile } from "./parse";
|
|
16
|
-
export type {
|
|
16
|
+
export type {
|
|
17
|
+
HandlerHeaderUpdate,
|
|
18
|
+
PatternChange,
|
|
19
|
+
PatternId,
|
|
20
|
+
QueryHandlerHeaderKey,
|
|
21
|
+
StreamHandlerHeaderKey,
|
|
22
|
+
WriteHandlerHeaderKey,
|
|
23
|
+
} from "./patch";
|
|
17
24
|
export {
|
|
18
25
|
addPattern,
|
|
19
26
|
applyChanges,
|
|
20
27
|
removePattern,
|
|
21
28
|
replacePattern,
|
|
22
29
|
SINGLETON_KINDS,
|
|
30
|
+
updatePattern,
|
|
23
31
|
} from "./patch";
|
|
24
32
|
export type {
|
|
25
33
|
AddAuthClaimsArgs,
|
|
@@ -52,6 +60,8 @@ export type {
|
|
|
52
60
|
FeaturePatcher,
|
|
53
61
|
} from "./patcher";
|
|
54
62
|
export { createFeaturePatcher } from "./patcher";
|
|
63
|
+
export type { PatternChangeIssue, PatternChangesParseResult } from "./pattern-change-schema";
|
|
64
|
+
export { parsePatternChanges } from "./pattern-change-schema";
|
|
55
65
|
export type {
|
|
56
66
|
AuthClaimsPattern,
|
|
57
67
|
ClaimKeyPattern,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Patch operations: apply add/replace/remove changes to a feature-file's
|
|
1
|
+
// Patch operations: apply add/replace/remove/update changes to a feature-file's
|
|
2
2
|
// SourceFile in-place, working at the r.*-call granularity. Custom code
|
|
3
3
|
// (helpers, comments, imports, anything between calls) survives every
|
|
4
4
|
// patch unchanged — the patcher only touches the spans it owns.
|
|
@@ -14,6 +14,9 @@
|
|
|
14
14
|
// - addPattern → appended at the end of the setup callback
|
|
15
15
|
// - replacePattern → in place, same indentation as the original call
|
|
16
16
|
// - removePattern → call + leading blank-line whitespace gone
|
|
17
|
+
// - updatePattern → only the named header properties of a handler's
|
|
18
|
+
// inline object literal change; everything else, comments included,
|
|
19
|
+
// stays byte-identical (no re-render)
|
|
17
20
|
//
|
|
18
21
|
// **Renderer-driven output.** Every pattern lands in canonical Object-
|
|
19
22
|
// Form (single-arg literal, see render.ts). Existing patterns in legacy
|
|
@@ -30,10 +33,22 @@
|
|
|
30
33
|
// roadmap C-Notes for the canonical-comment-attach Pattern that would
|
|
31
34
|
// preserve prefixed `// kumiko-comment:` markers across roundtrips.
|
|
32
35
|
|
|
33
|
-
import {
|
|
36
|
+
import {
|
|
37
|
+
type CallExpression,
|
|
38
|
+
type Node,
|
|
39
|
+
type ObjectLiteralExpression,
|
|
40
|
+
type SourceFile,
|
|
41
|
+
SyntaxKind,
|
|
42
|
+
} from "ts-morph";
|
|
34
43
|
import { readNameLiteral, readNameOrRef, resolveSameFileObjectLiteral } from "./extractors/shared";
|
|
35
|
-
import type {
|
|
36
|
-
|
|
44
|
+
import type {
|
|
45
|
+
FeaturePattern,
|
|
46
|
+
FeaturePatternKind,
|
|
47
|
+
QueryHandlerPattern,
|
|
48
|
+
StreamHandlerPattern,
|
|
49
|
+
WriteHandlerPattern,
|
|
50
|
+
} from "./patterns";
|
|
51
|
+
import { indent, PATTERN_INDENT, renderPattern, renderValue } from "./render";
|
|
37
52
|
|
|
38
53
|
// =============================================================================
|
|
39
54
|
// PatternId — natural-key per pattern kind
|
|
@@ -92,10 +107,48 @@ export type PatternId =
|
|
|
92
107
|
// Change ops — generic apply API
|
|
93
108
|
// =============================================================================
|
|
94
109
|
|
|
110
|
+
// Header-field keys per handler kind, derived from the pattern types so an
|
|
111
|
+
// added/renamed field changes these automatically.
|
|
112
|
+
export type WriteHandlerHeaderKey = Exclude<
|
|
113
|
+
keyof WriteHandlerPattern,
|
|
114
|
+
"kind" | "source" | "handlerName" | "schemaSource" | "handlerBody"
|
|
115
|
+
>;
|
|
116
|
+
export type QueryHandlerHeaderKey = Exclude<
|
|
117
|
+
keyof QueryHandlerPattern,
|
|
118
|
+
"kind" | "source" | "handlerName" | "schemaSource" | "handlerBody"
|
|
119
|
+
>;
|
|
120
|
+
export type StreamHandlerHeaderKey = Exclude<
|
|
121
|
+
keyof StreamHandlerPattern,
|
|
122
|
+
"kind" | "source" | "handlerName" | "schemaSource" | "handlerBody"
|
|
123
|
+
>;
|
|
124
|
+
|
|
125
|
+
// One member per handler kind so `set`/`unset` are typed against that
|
|
126
|
+
// kind's actual header fields.
|
|
127
|
+
export type HandlerHeaderUpdate =
|
|
128
|
+
| {
|
|
129
|
+
readonly op: "update";
|
|
130
|
+
readonly id: Extract<PatternId, { readonly kind: "writeHandler" }>;
|
|
131
|
+
readonly set: Partial<Pick<WriteHandlerPattern, WriteHandlerHeaderKey>>;
|
|
132
|
+
readonly unset?: readonly Exclude<WriteHandlerHeaderKey, "access">[];
|
|
133
|
+
}
|
|
134
|
+
| {
|
|
135
|
+
readonly op: "update";
|
|
136
|
+
readonly id: Extract<PatternId, { readonly kind: "queryHandler" }>;
|
|
137
|
+
readonly set: Partial<Pick<QueryHandlerPattern, QueryHandlerHeaderKey>>;
|
|
138
|
+
readonly unset?: readonly Exclude<QueryHandlerHeaderKey, "access">[];
|
|
139
|
+
}
|
|
140
|
+
| {
|
|
141
|
+
readonly op: "update";
|
|
142
|
+
readonly id: Extract<PatternId, { readonly kind: "streamHandler" }>;
|
|
143
|
+
readonly set: Partial<Pick<StreamHandlerPattern, StreamHandlerHeaderKey>>;
|
|
144
|
+
readonly unset?: readonly Exclude<StreamHandlerHeaderKey, "access">[];
|
|
145
|
+
};
|
|
146
|
+
|
|
95
147
|
export type PatternChange =
|
|
96
148
|
| { readonly op: "add"; readonly pattern: FeaturePattern }
|
|
97
149
|
| { readonly op: "replace"; readonly id: PatternId; readonly pattern: FeaturePattern }
|
|
98
|
-
| { readonly op: "remove"; readonly id: PatternId }
|
|
150
|
+
| { readonly op: "remove"; readonly id: PatternId }
|
|
151
|
+
| HandlerHeaderUpdate;
|
|
99
152
|
|
|
100
153
|
/**
|
|
101
154
|
* Apply a sequence of changes to the source file in-place. The list is
|
|
@@ -118,6 +171,9 @@ export function applyChanges(sourceFile: SourceFile, changes: readonly PatternCh
|
|
|
118
171
|
case "remove":
|
|
119
172
|
removePattern(sourceFile, change.id);
|
|
120
173
|
break;
|
|
174
|
+
case "update":
|
|
175
|
+
updatePattern(sourceFile, change);
|
|
176
|
+
break;
|
|
121
177
|
default: {
|
|
122
178
|
const _exhaustive: never = change;
|
|
123
179
|
throw new Error(`applyChanges: unknown op ${String(_exhaustive)}`);
|
|
@@ -254,6 +310,283 @@ export function removePattern(sourceFile: SourceFile, id: PatternId): void {
|
|
|
254
310
|
}
|
|
255
311
|
}
|
|
256
312
|
|
|
313
|
+
// =============================================================================
|
|
314
|
+
// Update — patch a subset of a handler pattern's header fields in place
|
|
315
|
+
// =============================================================================
|
|
316
|
+
|
|
317
|
+
const WRITE_HANDLER_HEADER_KEY_FLAGS: Readonly<Record<WriteHandlerHeaderKey, true>> = {
|
|
318
|
+
access: true,
|
|
319
|
+
description: true,
|
|
320
|
+
agent: true,
|
|
321
|
+
rateLimit: true,
|
|
322
|
+
unsafeSkipTransitionGuard: true,
|
|
323
|
+
escapeHatch: true,
|
|
324
|
+
};
|
|
325
|
+
const QUERY_HANDLER_HEADER_KEY_FLAGS: Readonly<Record<QueryHandlerHeaderKey, true>> = {
|
|
326
|
+
access: true,
|
|
327
|
+
description: true,
|
|
328
|
+
agent: true,
|
|
329
|
+
rateLimit: true,
|
|
330
|
+
escapeHatch: true,
|
|
331
|
+
};
|
|
332
|
+
const STREAM_HANDLER_HEADER_KEY_FLAGS: Readonly<Record<StreamHandlerHeaderKey, true>> = {
|
|
333
|
+
access: true,
|
|
334
|
+
rateLimit: true,
|
|
335
|
+
escapeHatch: true,
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
const HEADER_KEY_FLAGS_BY_HANDLER_KIND: Readonly<
|
|
339
|
+
Record<"writeHandler" | "queryHandler" | "streamHandler", Readonly<Record<string, true>>>
|
|
340
|
+
> = {
|
|
341
|
+
writeHandler: WRITE_HANDLER_HEADER_KEY_FLAGS,
|
|
342
|
+
queryHandler: QUERY_HANDLER_HEADER_KEY_FLAGS,
|
|
343
|
+
streamHandler: STREAM_HANDLER_HEADER_KEY_FLAGS,
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
type TextEdit = { readonly start: number; readonly end: number; readonly text: string };
|
|
347
|
+
|
|
348
|
+
function propertyName(prop: Node): string | undefined {
|
|
349
|
+
const assign = prop.asKind(SyntaxKind.PropertyAssignment);
|
|
350
|
+
if (assign) return readNameLiteral(assign.getNameNode()) ?? assign.getNameNode().getText();
|
|
351
|
+
const shorthand = prop.asKind(SyntaxKind.ShorthandPropertyAssignment);
|
|
352
|
+
if (shorthand) return shorthand.getName();
|
|
353
|
+
return undefined;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Append after the literal's last property, skipping any property this
|
|
357
|
+
// same change is about to unset — avoids interleaving the two edits' spans.
|
|
358
|
+
function lastRemainingProperty(
|
|
359
|
+
obj: ObjectLiteralExpression,
|
|
360
|
+
unsetKeys: ReadonlySet<string>,
|
|
361
|
+
): Node | undefined {
|
|
362
|
+
const props = obj.getProperties();
|
|
363
|
+
for (let i = props.length - 1; i >= 0; i--) {
|
|
364
|
+
const prop = props[i];
|
|
365
|
+
if (prop === undefined) continue;
|
|
366
|
+
const name = propertyName(prop);
|
|
367
|
+
if (name !== undefined && unsetKeys.has(name)) continue;
|
|
368
|
+
return prop;
|
|
369
|
+
}
|
|
370
|
+
return undefined;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function buildSetEdit(
|
|
374
|
+
obj: ObjectLiteralExpression,
|
|
375
|
+
key: string,
|
|
376
|
+
value: unknown,
|
|
377
|
+
unsetKeys: ReadonlySet<string>,
|
|
378
|
+
): TextEdit {
|
|
379
|
+
const sourceFile = obj.getSourceFile();
|
|
380
|
+
const existing = obj.getProperty(key);
|
|
381
|
+
const assign = existing?.asKind(SyntaxKind.PropertyAssignment);
|
|
382
|
+
if (assign) {
|
|
383
|
+
const init = assign.getInitializer();
|
|
384
|
+
if (init) {
|
|
385
|
+
// Column of the *property*, not the initializer — the initializer's
|
|
386
|
+
// further-right column would misalign a multi-line rendered value.
|
|
387
|
+
const col = sourceFile.getLineAndColumnAtPos(assign.getStart()).column;
|
|
388
|
+
return { start: init.getStart(), end: init.getEnd(), text: renderValue(value, col - 1) };
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
const shorthand = existing?.asKind(SyntaxKind.ShorthandPropertyAssignment);
|
|
392
|
+
if (shorthand) {
|
|
393
|
+
const col = sourceFile.getLineAndColumnAtPos(shorthand.getStart()).column;
|
|
394
|
+
return {
|
|
395
|
+
start: shorthand.getStart(),
|
|
396
|
+
end: shorthand.getEnd(),
|
|
397
|
+
text: `${key}: ${renderValue(value, col - 1)}`,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const last = lastRemainingProperty(obj, unsetKeys);
|
|
402
|
+
const fullText = sourceFile.getFullText();
|
|
403
|
+
const isSingleLineLiteral = !obj.getText().includes("\n");
|
|
404
|
+
|
|
405
|
+
if (!last) {
|
|
406
|
+
// Empty (or fully-unset) literal — insert right after the opening brace.
|
|
407
|
+
const openBrace = obj.getFirstChildByKindOrThrow(SyntaxKind.OpenBraceToken);
|
|
408
|
+
return {
|
|
409
|
+
start: openBrace.getEnd(),
|
|
410
|
+
end: openBrace.getEnd(),
|
|
411
|
+
text: ` ${key}: ${renderValue(value, 0)} `,
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (isSingleLineLiteral) {
|
|
416
|
+
let pos = last.getEnd();
|
|
417
|
+
const hasComma = fullText[pos] === ",";
|
|
418
|
+
if (hasComma) pos++;
|
|
419
|
+
return {
|
|
420
|
+
start: pos,
|
|
421
|
+
end: pos,
|
|
422
|
+
text: `${hasComma ? "" : ","} ${key}: ${renderValue(value, 0)}`,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const col = sourceFile.getLineAndColumnAtPos(last.getStart()).column;
|
|
427
|
+
const indentStr = " ".repeat(col - 1);
|
|
428
|
+
const valueEnd = last.getEnd();
|
|
429
|
+
const hasComma = fullText[valueEnd] === ",";
|
|
430
|
+
const contentStart = hasComma ? valueEnd + 1 : valueEnd;
|
|
431
|
+
const lineEndPos = lineEnd(sourceFile, contentStart);
|
|
432
|
+
// Rest of the line after the value/comma — normally empty, or a trailing comment.
|
|
433
|
+
const restOfLine = fullText.slice(contentStart, lineEndPos);
|
|
434
|
+
const restIsBlankOrComment = restOfLine.trim().length === 0 || restOfLine.trim().startsWith("//");
|
|
435
|
+
if (!restIsBlankOrComment) {
|
|
436
|
+
// Last property shares its line with other content (e.g. `});`) —
|
|
437
|
+
// appending here would land after it. Narrower than replace; bail.
|
|
438
|
+
throw new Error(
|
|
439
|
+
"updatePattern: cannot append a header field — the handler literal's last property shares a line with other code; use replace",
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
// Reconstruct the comma here (not inserted after the value) so it lands
|
|
443
|
+
// right after the value, never inside a trailing `//` comment.
|
|
444
|
+
return {
|
|
445
|
+
start: valueEnd,
|
|
446
|
+
end: lineEndPos,
|
|
447
|
+
text: `,${restOfLine}\n${indentStr}${key}: ${renderValue(value, col - 1)},`,
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Whole-line delete: property was alone on its line (with its indentation
|
|
452
|
+
// and trailing newline) — used when nothing else shares that line.
|
|
453
|
+
function buildWholeLineUnsetEdit(
|
|
454
|
+
sourceFile: SourceFile,
|
|
455
|
+
lineStartPos: number,
|
|
456
|
+
afterPos: number,
|
|
457
|
+
): TextEdit {
|
|
458
|
+
const text = sourceFile.getFullText();
|
|
459
|
+
let end = afterPos;
|
|
460
|
+
while (end < text.length && text[end] !== "\n") end++;
|
|
461
|
+
if (end < text.length) end++;
|
|
462
|
+
return { start: lineStartPos, end, text: "" };
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Inline delete: property shares its line with other content — remove only
|
|
466
|
+
// the property plus its trailing comma+space, or (last on the line, no
|
|
467
|
+
// trailing comma) the preceding comma+space.
|
|
468
|
+
function buildInlineUnsetEdit(
|
|
469
|
+
text: string,
|
|
470
|
+
propStart: number,
|
|
471
|
+
afterPos: number,
|
|
472
|
+
hasTrailingComma: boolean,
|
|
473
|
+
): TextEdit {
|
|
474
|
+
let start = propStart;
|
|
475
|
+
let end = afterPos;
|
|
476
|
+
if (hasTrailingComma && text[end] === " ") end++;
|
|
477
|
+
if (!hasTrailingComma) {
|
|
478
|
+
let precedingStart = propStart;
|
|
479
|
+
while (precedingStart > 0 && text[precedingStart - 1] === " ") precedingStart--;
|
|
480
|
+
if (text[precedingStart - 1] === ",") start = precedingStart - 1;
|
|
481
|
+
}
|
|
482
|
+
return { start, end, text: "" };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function buildUnsetEdit(obj: ObjectLiteralExpression, key: string): TextEdit | undefined {
|
|
486
|
+
const prop = obj.getProperty(key);
|
|
487
|
+
if (!prop) return undefined; // no-op: nothing to remove, unset is idempotent
|
|
488
|
+
const sourceFile = obj.getSourceFile();
|
|
489
|
+
const text = sourceFile.getFullText();
|
|
490
|
+
|
|
491
|
+
const propStart = prop.getStart();
|
|
492
|
+
const lineStartPos = lineStart(sourceFile, propStart);
|
|
493
|
+
const beforeOnLine = text.slice(lineStartPos, propStart);
|
|
494
|
+
|
|
495
|
+
let afterPos = prop.getEnd();
|
|
496
|
+
const hasTrailingComma = text[afterPos] === ",";
|
|
497
|
+
if (hasTrailingComma) afterPos++;
|
|
498
|
+
let probe = afterPos;
|
|
499
|
+
while (probe < text.length && (text[probe] === " " || text[probe] === "\t")) probe++;
|
|
500
|
+
const restOfLine = text.slice(probe, lineEnd(sourceFile, probe));
|
|
501
|
+
|
|
502
|
+
// Whole-line delete only when the property is alone on its line — else
|
|
503
|
+
// this would eat sibling properties or the enclosing call's `});`.
|
|
504
|
+
const aloneOnLine =
|
|
505
|
+
beforeOnLine.trim().length === 0 &&
|
|
506
|
+
(restOfLine.trim().length === 0 || restOfLine.trim().startsWith("//"));
|
|
507
|
+
if (aloneOnLine) return buildWholeLineUnsetEdit(sourceFile, lineStartPos, afterPos);
|
|
508
|
+
return buildInlineUnsetEdit(text, propStart, afterPos, hasTrailingComma);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Apply a `HandlerHeaderUpdate` to the header fields named in `set`/`unset`
|
|
513
|
+
* only, via targeted text edits — `schemaSource`, `handlerBody`, comments,
|
|
514
|
+
* and every unnamed field stay byte-identical (narrower than replacePattern).
|
|
515
|
+
*/
|
|
516
|
+
export function updatePattern(sourceFile: SourceFile, change: HandlerHeaderUpdate): void {
|
|
517
|
+
const call = findCallForId(sourceFile, change.id);
|
|
518
|
+
if (!call) {
|
|
519
|
+
throw new Error(`updatePattern: no call found for ${describeId(change.id)}`);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const args = call.getArguments();
|
|
523
|
+
if (args.length !== 1) {
|
|
524
|
+
throw new Error(
|
|
525
|
+
`updatePattern: ${describeId(change.id)} uses the positional call form; update requires the object form; use replace`,
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
const arg0 = args[0];
|
|
529
|
+
if (!arg0) {
|
|
530
|
+
throw new Error(`updatePattern: no call found for ${describeId(change.id)}`);
|
|
531
|
+
}
|
|
532
|
+
const obj = arg0.asKind(SyntaxKind.ObjectLiteralExpression);
|
|
533
|
+
if (!obj) {
|
|
534
|
+
throw new Error(
|
|
535
|
+
`updatePattern: ${describeId(change.id)} is not an inline object literal; use replace`,
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (obj.getProperties().some((prop) => prop.isKind(SyntaxKind.SpreadAssignment))) {
|
|
540
|
+
throw new Error(
|
|
541
|
+
"updatePattern: cannot update a handler literal containing a spread; use replace",
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const headerFlags = HEADER_KEY_FLAGS_BY_HANDLER_KIND[change.id.kind];
|
|
546
|
+
const setEntries: [string, unknown][] = Object.entries(change.set).filter(
|
|
547
|
+
([, v]) => v !== undefined,
|
|
548
|
+
);
|
|
549
|
+
for (const [key] of setEntries) {
|
|
550
|
+
if (!Object.hasOwn(headerFlags, key)) {
|
|
551
|
+
throw new Error(`updatePattern: "${key}" is not a header field of ${change.id.kind}`);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
// Widen to `string`: this is a runtime boundary, re-check even though the
|
|
555
|
+
// static type already excludes "access" from `unset`.
|
|
556
|
+
const unsetKeyList: readonly string[] = change.unset ?? [];
|
|
557
|
+
const setKeySet = new Set(setEntries.map(([key]) => key));
|
|
558
|
+
for (const key of unsetKeyList) {
|
|
559
|
+
if (!Object.hasOwn(headerFlags, key)) {
|
|
560
|
+
throw new Error(`updatePattern: "${key}" is not a header field of ${change.id.kind}`);
|
|
561
|
+
}
|
|
562
|
+
if (key === "access") {
|
|
563
|
+
throw new Error("updatePattern: access is required and cannot be unset");
|
|
564
|
+
}
|
|
565
|
+
if (setKeySet.has(key)) {
|
|
566
|
+
throw new Error(`updatePattern: "${key}" is in both set and unset`);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Dedupe: two identical `unset` entries would otherwise produce two
|
|
571
|
+
// overlapping deletes, the second eating text past the first's shrink.
|
|
572
|
+
const unsetKeys = new Set<string>(unsetKeyList);
|
|
573
|
+
const edits: TextEdit[] = [
|
|
574
|
+
...setEntries.map(([key, value]) => buildSetEdit(obj, key, value, unsetKeys)),
|
|
575
|
+
...[...unsetKeys]
|
|
576
|
+
.map((key) => buildUnsetEdit(obj, key))
|
|
577
|
+
.filter((edit): edit is TextEdit => edit !== undefined),
|
|
578
|
+
];
|
|
579
|
+
|
|
580
|
+
// Apply from the highest offset down so an earlier edit's positions,
|
|
581
|
+
// captured above against the pre-edit text, stay valid.
|
|
582
|
+
edits
|
|
583
|
+
.slice()
|
|
584
|
+
.sort((a, b) => b.start - a.start)
|
|
585
|
+
.forEach((edit) => {
|
|
586
|
+
sourceFile.replaceText([edit.start, edit.end], edit.text);
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
|
|
257
590
|
// =============================================================================
|
|
258
591
|
// Lookup
|
|
259
592
|
// =============================================================================
|
|
@@ -62,14 +62,14 @@ import type { SourceLocation } from "./source-location";
|
|
|
62
62
|
// that never reaches the output.
|
|
63
63
|
// =============================================================================
|
|
64
64
|
|
|
65
|
-
const SYNTHETIC_LOC: SourceLocation = {
|
|
65
|
+
export const SYNTHETIC_LOC: SourceLocation = {
|
|
66
66
|
file: "<patcher>",
|
|
67
67
|
start: { line: 1, column: 1 },
|
|
68
68
|
end: { line: 1, column: 1 },
|
|
69
69
|
raw: "",
|
|
70
70
|
};
|
|
71
71
|
|
|
72
|
-
function rawLoc(raw: string): SourceLocation {
|
|
72
|
+
export function rawLoc(raw: string): SourceLocation {
|
|
73
73
|
return { ...SYNTHETIC_LOC, raw };
|
|
74
74
|
}
|
|
75
75
|
|