@jini-ai/core 0.4.8 → 0.6.10

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.
Files changed (77) hide show
  1. package/CHANGELOG.md +23 -2
  2. package/README.md +5 -0
  3. package/dist/agent-tools.d.ts +1 -1
  4. package/dist/approval.d.ts +15 -0
  5. package/dist/approval.js +87 -22
  6. package/dist/batch-tool-inputs.d.ts +147 -0
  7. package/dist/batch-tool-inputs.js +257 -0
  8. package/dist/feature-flags.d.ts +21 -0
  9. package/dist/feature-flags.js +17 -0
  10. package/dist/fetch/index.d.ts +2 -1
  11. package/dist/fetch/index.js +1 -0
  12. package/dist/fetch/loader-read.d.ts +16 -0
  13. package/dist/fetch/loader-read.js +22 -0
  14. package/dist/fetch/types.d.ts +12 -2
  15. package/dist/gated-mutations/gateway.js +32 -27
  16. package/dist/index.d.ts +8 -1
  17. package/dist/index.js +7 -1
  18. package/dist/object-store.d.ts +115 -0
  19. package/dist/object-store.js +70 -0
  20. package/dist/origin-validation.js +64 -57
  21. package/dist/primitives/http.d.ts +2 -2
  22. package/dist/primitives/index.d.ts +1 -0
  23. package/dist/primitives/index.js +1 -0
  24. package/dist/primitives/paths.js +17 -10
  25. package/dist/primitives/tasks.d.ts +10 -0
  26. package/dist/primitives/tasks.js +8 -0
  27. package/dist/provider-catalog.d.ts +76 -0
  28. package/dist/provider-catalog.js +6 -0
  29. package/dist/provider-registry.d.ts +144 -0
  30. package/dist/provider-registry.js +138 -0
  31. package/dist/records.d.ts +24 -0
  32. package/dist/records.js +25 -0
  33. package/dist/redact.d.ts +32 -0
  34. package/dist/redact.js +91 -34
  35. package/dist/registration-kit.d.ts +7 -4
  36. package/dist/registration-kit.js +81 -62
  37. package/dist/settings/agent-value-write-tools.d.ts +19 -0
  38. package/dist/settings/agent-value-write-tools.js +131 -70
  39. package/dist/settings/ensure-definitions.d.ts +4 -2
  40. package/dist/settings/ensure-definitions.js +79 -63
  41. package/dist/settings/errors.d.ts +13 -0
  42. package/dist/settings/errors.js +15 -0
  43. package/dist/settings/express/cms-adapter.d.ts +2 -2
  44. package/dist/settings/express/cms-adapter.js +34 -22
  45. package/dist/settings/express/http-request.d.ts +33 -0
  46. package/dist/settings/express/http-request.js +49 -0
  47. package/dist/settings/express/http.d.ts +2 -2
  48. package/dist/settings/express/http.js +16 -39
  49. package/dist/settings/express/stream.d.ts +2 -2
  50. package/dist/settings/express/stream.js +49 -27
  51. package/dist/settings/index.d.ts +2 -2
  52. package/dist/settings/index.js +1 -1
  53. package/dist/settings/ports.d.ts +7 -1
  54. package/dist/settings/repo.memory.js +7 -4
  55. package/dist/settings/settings.js +54 -60
  56. package/dist/settings/tool-registrations.d.ts +2 -3
  57. package/dist/settings/tool-registrations.js +64 -39
  58. package/dist/settings/write-service.d.ts +42 -4
  59. package/dist/settings/write-service.js +72 -5
  60. package/dist/target-scope.d.ts +2 -0
  61. package/dist/target-scope.js +18 -2
  62. package/dist/text/escape.js +2 -2
  63. package/dist/text/sanitize-untrusted-text.d.ts +5 -0
  64. package/dist/text/sanitize-untrusted-text.js +55 -35
  65. package/dist/tool-exposure.d.ts +79 -0
  66. package/dist/tool-exposure.js +163 -0
  67. package/dist/tool-input-errors.d.ts +23 -0
  68. package/dist/tool-input-errors.js +24 -0
  69. package/dist/tool-input-normalization.d.ts +37 -0
  70. package/dist/tool-input-normalization.js +188 -0
  71. package/dist/tool-metadata-types.d.ts +26 -0
  72. package/dist/tool-metadata-types.js +2 -0
  73. package/dist/tool-metadata.d.ts +2 -23
  74. package/dist/tool-registry.d.ts +38 -22
  75. package/dist/tool-registry.js +35 -20
  76. package/package.json +20 -2
  77. package/plugin-directory.js +72 -33
@@ -0,0 +1,49 @@
1
+ import { respondToSettingsError } from "./shared.js";
2
+ /**
3
+ * @file Request-level steps of the settings HTTP registrar (`http.ts`): the
4
+ * authentication gate, the failure envelope and value-write body parsing.
5
+ * Pure or response-only; no service, authorization or storage effects.
6
+ */
7
+ export const SETTING_SCOPES = ["global", "workspace", "user"];
8
+ export function unauthenticated(res) {
9
+ res.status(401).json({ error: "unauthenticated", code: "UNAUTHENTICATED" });
10
+ }
11
+ /** Readiness/principal failures preserve the authentication boundary's existing 401 envelope, so both collapse to null. */
12
+ export async function resolvePrincipal(required, context) {
13
+ try {
14
+ await required.ready;
15
+ return await required.principalResolver(context);
16
+ }
17
+ catch {
18
+ return null;
19
+ }
20
+ }
21
+ /** Map a handler failure to its envelope, or end a response whose headers have already been sent. */
22
+ export function failRequest(required, optional) {
23
+ const { response, error, mappings } = required;
24
+ if (!response.headersSent) {
25
+ respondToSettingsError({ response, error, mappings });
26
+ return;
27
+ }
28
+ // Reporting cannot prevent ending a response whose headers have already been sent.
29
+ try {
30
+ optional.onError?.({ operation: "settings-request", error });
31
+ }
32
+ catch { /* Host diagnostics are best-effort. */ }
33
+ finally {
34
+ response.end();
35
+ }
36
+ }
37
+ export const VALUE_MUTATION_REQUIRED = {
38
+ set: "namespace, key, scope (global|workspace|user), and valueJson are required",
39
+ clear: "namespace, key, and scope (global|workspace|user) are required",
40
+ };
41
+ /** The fields a value write needs, or null when one is missing or the scope is unknown. Pure, O(1). */
42
+ export function readValueMutation(body, operation) {
43
+ const namespace = String(body.namespace ?? "");
44
+ const key = String(body.key ?? "");
45
+ const scope = body.scope;
46
+ const complete = namespace && key && SETTING_SCOPES.includes(scope) && (operation === "clear" || "valueJson" in body);
47
+ return complete ? { namespace, key, scope } : null;
48
+ }
49
+ //# sourceMappingURL=http-request.js.map
@@ -1,6 +1,6 @@
1
1
  import type { SettingsHttpRequired, SettingsHttpOptions } from "./contracts.js";
2
- /** Mount all eight endpoints. The returned disposer closes active feeds and cancels their schedules. */
3
- /** Injected policy and services preserve the settings HTTP response envelopes.
2
+ /** Mount all eight endpoints. The returned disposer closes active feeds and cancels their schedules.
3
+ * Injected policy and services preserve the settings HTTP response envelopes.
4
4
  * O(1) mounting; read/mutation work is delegated. Feed resources are bounded per connection.
5
5
  * @example registerSettingsRoutes({ app, workspaceId, ready, service, changeFeed, principalResolver, authorize, scheduler, routes, permissions });
6
6
  */
@@ -1,6 +1,7 @@
1
1
  import { AliasDepthExceededError, DefinitionInvalidError, DefinitionNotFoundError, DefinitionTombstonedError, ForbiddenError, PrincipalNotFoundError, RenameRetypeConflictError, ScopeNotAllowedError, SecretNotSupportedError, ValueValidationFailedError, } from "../index.js";
2
- import { respondToSettingsError, resolveTargetWorkspaceId, resolveUserLayerReadTarget, settingsWritePermission } from "./shared.js";
2
+ import { resolveTargetWorkspaceId, resolveUserLayerReadTarget, settingsWritePermission } from "./shared.js";
3
3
  import { openSettingsChangeStream } from "./stream.js";
4
+ import { failRequest, readValueMutation, resolvePrincipal, SETTING_SCOPES, unauthenticated, VALUE_MUTATION_REQUIRED } from "./http-request.js";
4
5
  const VALUE_ERRORS = [
5
6
  { matches: (e) => e instanceof PrincipalNotFoundError, status: 404, code: "PRINCIPAL_NOT_FOUND" },
6
7
  { matches: (e) => e instanceof DefinitionNotFoundError, status: 404, code: "DEFINITION_NOT_FOUND" },
@@ -18,7 +19,6 @@ const DEFINITION_ERRORS = [
18
19
  { matches: (e) => e instanceof ForbiddenError, status: 403, code: "FORBIDDEN" },
19
20
  ];
20
21
  const RESET_ERRORS = VALUE_ERRORS.filter((m) => m.code === "FORBIDDEN");
21
- const scopes = ["global", "workspace", "user"];
22
22
  function validation(res, error) {
23
23
  res.status(400).json({ error, code: "VALIDATION_ERROR" });
24
24
  }
@@ -29,8 +29,8 @@ function denied(res, principalId, permission, reason) {
29
29
  function bodyOf(req) {
30
30
  return req.body && typeof req.body === "object" && !Array.isArray(req.body) ? req.body : {};
31
31
  }
32
- /** Mount all eight endpoints. The returned disposer closes active feeds and cancels their schedules. */
33
- /** Injected policy and services preserve the settings HTTP response envelopes.
32
+ /** Mount all eight endpoints. The returned disposer closes active feeds and cancels their schedules.
33
+ * Injected policy and services preserve the settings HTTP response envelopes.
34
34
  * O(1) mounting; read/mutation work is delegated. Feed resources are bounded per connection.
35
35
  * @example registerSettingsRoutes({ app, workspaceId, ready, service, changeFeed, principalResolver, authorize, scheduler, routes, permissions });
36
36
  */
@@ -47,36 +47,16 @@ export function registerSettingsRoutes(required, optional = {}) {
47
47
  res.status(404).json({ error: "workspace was not found" });
48
48
  return;
49
49
  }
50
- let principal;
51
- try {
52
- await required.ready;
53
- principal = await required.principalResolver({ request: req, response: res });
54
- }
55
- catch {
56
- // Readiness/principal failures preserve the authentication boundary's existing 401 envelope.
57
- res.status(401).json({ error: "unauthenticated", code: "UNAUTHENTICATED" });
58
- return;
59
- }
50
+ const principal = await resolvePrincipal(required, { request: req, response: res });
60
51
  if (!principal) {
61
- res.status(401).json({ error: "unauthenticated", code: "UNAUTHENTICATED" });
52
+ unauthenticated(res);
62
53
  return;
63
54
  }
64
55
  try {
65
56
  await handler(req, res, principal.id);
66
57
  }
67
58
  catch (error) {
68
- if (res.headersSent) {
69
- // Reporting cannot prevent ending a response whose headers have already been sent.
70
- try {
71
- optional.onError?.({ operation: "settings-request", error });
72
- }
73
- catch { /* Host diagnostics are best-effort. */ }
74
- finally {
75
- res.end();
76
- }
77
- }
78
- else
79
- respondToSettingsError({ response: res, error, mappings });
59
+ failRequest({ response: res, error, mappings }, optional);
80
60
  }
81
61
  };
82
62
  const check = async (res, principalId, permission, entityType) => {
@@ -152,13 +132,12 @@ export function registerSettingsRoutes(required, optional = {}) {
152
132
  }, DEFINITION_ERRORS));
153
133
  const mutate = (operation) => async (req, res, callerPrincipalId) => {
154
134
  const body = bodyOf(req);
155
- const namespace = String(body.namespace ?? "");
156
- const key = String(body.key ?? "");
157
- const scope = body.scope;
158
- if (!namespace || !key || !scopes.includes(scope) || (operation === "set" && !("valueJson" in body))) {
159
- validation(res, operation === "set" ? "namespace, key, scope (global|workspace|user), and valueJson are required" : "namespace, key, and scope (global|workspace|user) are required");
135
+ const fields = readValueMutation(body, operation);
136
+ if (!fields) {
137
+ validation(res, VALUE_MUTATION_REQUIRED[operation]);
160
138
  return;
161
139
  }
140
+ const { namespace, key, scope } = fields;
162
141
  const target = resolveTargetWorkspaceId({ workspaceId, input: { bodyWorkspaceId: body.workspaceId, scope } });
163
142
  if (!target.ok) {
164
143
  validation(res, target.error);
@@ -178,7 +157,7 @@ export function registerSettingsRoutes(required, optional = {}) {
178
157
  const body = bodyOf(req);
179
158
  const namespace = String(body.namespace ?? "");
180
159
  const scope = body.scope;
181
- if (!namespace || !scopes.includes(scope)) {
160
+ if (!namespace || !SETTING_SCOPES.includes(scope)) {
182
161
  validation(res, "namespace and scope (global|workspace|user) are required");
183
162
  return;
184
163
  }
@@ -198,12 +177,10 @@ export function registerSettingsRoutes(required, optional = {}) {
198
177
  const close = await openSettingsChangeStream({ request: req, response: res, workspaceId, principalId,
199
178
  changeFeed: required.changeFeed, authorize: required.authorize, permission: permissions.read, scheduler: required.scheduler,
200
179
  onClose: ({ close }) => { streams.delete(close); }, isDisposed: () => disposed }, optional);
201
- if (close) {
202
- if (disposed)
203
- close();
204
- else if (!res.destroyed && !res.writableEnded)
205
- streams.add(close);
206
- }
180
+ if (disposed)
181
+ close();
182
+ else if (!res.destroyed && !res.writableEnded)
183
+ streams.add(close);
207
184
  }));
208
185
  return { dispose: () => { disposed = true; for (const close of streams)
209
186
  close(); streams.clear(); } };
@@ -14,8 +14,8 @@ interface StreamRequired {
14
14
  }) => void;
15
15
  isDisposed: () => boolean;
16
16
  }
17
- /** Starts after HTTP authorization. All scheduling and storage are injected. */
18
- /** Poll with bounded pages, tenant-scoped ids and retryable reads; close cancels all schedules.
17
+ /** Starts after HTTP authorization. All scheduling and storage are injected.
18
+ * Poll with bounded pages, tenant-scoped ids and retryable reads; close cancels all schedules.
19
19
  * O(page size) per tick and O(1) connection state; repository effects live behind the feed port.
20
20
  * Invalid scheduler intervals/page sizes throw RangeError before opening; initial read errors propagate.
21
21
  */
@@ -1,29 +1,16 @@
1
- /** Starts after HTTP authorization. All scheduling and storage are injected. */
2
- /** Poll with bounded pages, tenant-scoped ids and retryable reads; close cancels all schedules.
1
+ /** Starts after HTTP authorization. All scheduling and storage are injected.
2
+ * Poll with bounded pages, tenant-scoped ids and retryable reads; close cancels all schedules.
3
3
  * O(page size) per tick and O(1) connection state; repository effects live behind the feed port.
4
4
  * Invalid scheduler intervals/page sizes throw RangeError before opening; initial read errors propagate.
5
5
  */
6
6
  export async function openSettingsChangeStream(required, optional = {}) {
7
7
  const { request, response, workspaceId, principalId, changeFeed, scheduler } = required;
8
- const pageSize = optional.revisionPageSize ?? 200;
9
- const intervals = [optional.pollIntervalMs ?? 1_000, optional.keepaliveIntervalMs ?? 25_000, optional.reauthorizeIntervalMs ?? 30_000];
10
- if (!Number.isSafeInteger(pageSize) || pageSize <= 0 || intervals.some((n) => !Number.isFinite(n) || n <= 0)) {
11
- throw new RangeError("settings feed page size and intervals must be positive");
12
- }
8
+ const { pageSize, pollIntervalMs, keepaliveIntervalMs, reauthorizeIntervalMs } = readStreamTimings(optional);
13
9
  let closed = false;
14
10
  let ticking = false;
15
11
  let checkingAuthorization = false;
16
12
  const cancellations = [];
17
- const reportError = (operation, error) => {
18
- // Reporting is best-effort: a host hook must never turn recovery into an unhandled rejection.
19
- try {
20
- if (optional.onError)
21
- optional.onError({ operation, error });
22
- else
23
- console.error(`[settings] change feed ${operation} failed`, error);
24
- }
25
- catch { /* Diagnostic failures cannot interrupt stream cleanup or retries. */ }
26
- };
13
+ const reportError = createErrorReporter(optional);
27
14
  const recover = (operation, effect) => {
28
15
  try {
29
16
  effect();
@@ -52,18 +39,15 @@ export async function openSettingsChangeStream(required, optional = {}) {
52
39
  response.on("close", close);
53
40
  try {
54
41
  const head = await changeFeed.head({});
55
- if (closed || required.isDisposed() || response.destroyed || response.writableEnded) {
42
+ const gone = () => closed || required.isDisposed() || response.destroyed || response.writableEnded;
43
+ if (gone()) {
56
44
  close();
57
45
  return close;
58
46
  }
59
- const resumeFrom = Number(request.headers["last-event-id"]);
60
- let cursor = Number.isFinite(resumeFrom) && resumeFrom >= 0 ? Math.min(resumeFrom, head) : head;
47
+ let cursor = resumeCursor({ lastEventId: request.headers["last-event-id"], head });
61
48
  let lastEmittedId = cursor;
62
49
  const viewer = { workspaceId, principalId };
63
- response.writeHead(200, {
64
- "Content-Type": "text/event-stream", "Cache-Control": "no-cache, no-transform",
65
- ...(request.httpVersionMajor < 2 ? { Connection: "keep-alive" } : {}), "X-Accel-Buffering": "no",
66
- });
50
+ response.writeHead(200, sseHeaders({ httpVersionMajor: request.httpVersionMajor }));
67
51
  const tick = async () => {
68
52
  if (closed || ticking)
69
53
  return;
@@ -116,8 +100,8 @@ export async function openSettingsChangeStream(required, optional = {}) {
116
100
  else
117
101
  cancellations.push(cancel);
118
102
  };
119
- schedule(intervals[0], () => { void tick(); });
120
- schedule(intervals[1], () => {
103
+ schedule(pollIntervalMs, () => { void tick(); });
104
+ schedule(keepaliveIntervalMs, () => {
121
105
  if (closed)
122
106
  return;
123
107
  try {
@@ -128,7 +112,7 @@ export async function openSettingsChangeStream(required, optional = {}) {
128
112
  close();
129
113
  }
130
114
  });
131
- schedule(intervals[2], () => { void reauthorize(); });
115
+ schedule(reauthorizeIntervalMs, () => { void reauthorize(); });
132
116
  if (!closed)
133
117
  response.write(": connected\n\n");
134
118
  return close;
@@ -144,4 +128,42 @@ export async function openSettingsChangeStream(required, optional = {}) {
144
128
  throw error;
145
129
  }
146
130
  }
131
+ /** Fill the documented defaults, then refuse any non-positive page size or interval before an effect. Pure. */
132
+ function readStreamTimings(optional) {
133
+ const timings = {
134
+ pageSize: optional.revisionPageSize ?? 200,
135
+ pollIntervalMs: optional.pollIntervalMs ?? 1_000,
136
+ keepaliveIntervalMs: optional.keepaliveIntervalMs ?? 25_000,
137
+ reauthorizeIntervalMs: optional.reauthorizeIntervalMs ?? 30_000,
138
+ };
139
+ const intervals = [timings.pollIntervalMs, timings.keepaliveIntervalMs, timings.reauthorizeIntervalMs];
140
+ if (!Number.isSafeInteger(timings.pageSize) || timings.pageSize <= 0 || intervals.some((n) => !Number.isFinite(n) || n <= 0)) {
141
+ throw new RangeError("settings feed page size and intervals must be positive");
142
+ }
143
+ return timings;
144
+ }
145
+ function createErrorReporter(optional) {
146
+ return (operation, error) => {
147
+ // Reporting is best-effort: a host hook must never turn recovery into an unhandled rejection.
148
+ try {
149
+ if (optional.onError)
150
+ optional.onError({ operation, error });
151
+ else
152
+ console.error(`[settings] change feed ${operation} failed`, error);
153
+ }
154
+ catch { /* Diagnostic failures cannot interrupt stream cleanup or retries. */ }
155
+ };
156
+ }
157
+ /** A client's Last-Event-ID resumes at or before the ledger head; a missing or unusable id starts at head. Pure. */
158
+ function resumeCursor(required) {
159
+ const resumeFrom = Number(required.lastEventId);
160
+ return Number.isFinite(resumeFrom) && resumeFrom >= 0 ? Math.min(resumeFrom, required.head) : required.head;
161
+ }
162
+ /** HTTP/2 forbids the Connection header; HTTP/1 needs it to keep the stream open. Pure. */
163
+ function sseHeaders(required) {
164
+ return {
165
+ "Content-Type": "text/event-stream", "Cache-Control": "no-cache, no-transform",
166
+ ...(required.httpVersionMajor < 2 ? { Connection: "keep-alive" } : {}), "X-Accel-Buffering": "no",
167
+ };
168
+ }
147
169
  //# sourceMappingURL=stream.js.map
@@ -11,11 +11,11 @@
11
11
  * `SettingsRepoPort` this barrel exports.
12
12
  */
13
13
  export { SCOPE_BIT, type SettingScope, type SettingOwnerKind, type DefinitionStatus, type ValueState, type RevisionEntityKind, type RevisionOp, type SettingValueSchema, type SettingDefinitionRecord, type SettingValueRecord, type SettingRevisionRecord, type SettingScopeContext, } from "./types.js";
14
- export { DefinitionInvalidError, ScopeNotAllowedError, SecretNotSupportedError, ValueValidationFailedError, RenameRetypeConflictError, AliasDepthExceededError, DefinitionTombstonedError, DefinitionNotFoundError, PurgeRequiredError, ForbiddenError, PrincipalNotFoundError, } from "./errors.js";
14
+ export { DefinitionInvalidError, ScopeNotAllowedError, SecretNotSupportedError, ValueValidationFailedError, SettingsRevisionConflictError, RenameRetypeConflictError, AliasDepthExceededError, DefinitionTombstonedError, DefinitionNotFoundError, PurgeRequiredError, ForbiddenError, PrincipalNotFoundError, } from "./errors.js";
15
15
  export type { SettingsRepoPort } from "./ports.js";
16
16
  export { InMemorySettingsRepo } from "./repo.memory.js";
17
17
  export { type DefinitionInput, validateDefinitionInput, validateValueAgainstSchema, registerCoercer, invalidateDefinitionNamespaceCache, invalidateWorkspaceSettingsCache, resolveDefinitionRaw, resolveDefinition, type ResolvedSetting, getEffective, } from "./settings.js";
18
- export { type AuthorizeFn, type SettingsWriteServiceDeps, deriveRequiredPermission, type RegisterDefinitionsRequired, registerDefinitions, type SetValueRequired, set, type ClearValueRequired, clear, type ResetNamespaceRequired, resetNamespace, type RenameDefinitionRequired, renameDefinition, type RetypeDefinitionRequired, retypeDefinition, type ReconcileDefinitionDefaultRequired, reconcileDefinitionDefault, type DeprecateDefinitionRequired, deprecateDefinition, type TombstoneDefinitionRequired, tombstoneDefinition, } from "./write-service.js";
18
+ export { type AuthorizeFn, type SettingsWriteServiceDeps, deriveRequiredPermission, type RegisterDefinitionsRequired, registerDefinitions, type SetValueRequired, type SettingsValueWriteOptions, set, type ClearValueRequired, clear, type ResetNamespaceRequired, resetNamespace, type RenameDefinitionRequired, renameDefinition, type RetypeDefinitionRequired, retypeDefinition, type ReconcileDefinitionDefaultRequired, reconcileDefinitionDefault, type DeprecateDefinitionRequired, deprecateDefinition, type TombstoneDefinitionRequired, tombstoneDefinition, } from "./write-service.js";
19
19
  export { type PurgeServiceDeps, type PurgeTenantSettingsRequired, purgeTenantSettings, } from "./purge-service.js";
20
20
  export { type ChangeFeedViewer, isRevisionVisibleTo, type ChangeFeedBatch, collectChangedNamespaces, } from "./change-feed.js";
21
21
  export { type DefinitionOpRequestItem, type DefinitionOpContext, type DefinitionOpHandler, NON_REGISTER_DEFINITION_OP_NAMES, type NonRegisterDefinitionOp, parseNonRegisterDefinitionOp, NON_REGISTER_DEFINITION_OPS, } from "./definitions-dispatch.js";
@@ -11,7 +11,7 @@
11
11
  * `SettingsRepoPort` this barrel exports.
12
12
  */
13
13
  export { SCOPE_BIT, } from "./types.js";
14
- export { DefinitionInvalidError, ScopeNotAllowedError, SecretNotSupportedError, ValueValidationFailedError, RenameRetypeConflictError, AliasDepthExceededError, DefinitionTombstonedError, DefinitionNotFoundError, PurgeRequiredError, ForbiddenError, PrincipalNotFoundError, } from "./errors.js";
14
+ export { DefinitionInvalidError, ScopeNotAllowedError, SecretNotSupportedError, ValueValidationFailedError, SettingsRevisionConflictError, RenameRetypeConflictError, AliasDepthExceededError, DefinitionTombstonedError, DefinitionNotFoundError, PurgeRequiredError, ForbiddenError, PrincipalNotFoundError, } from "./errors.js";
15
15
  export { InMemorySettingsRepo } from "./repo.memory.js";
16
16
  export { validateDefinitionInput, validateValueAgainstSchema, registerCoercer, invalidateDefinitionNamespaceCache, invalidateWorkspaceSettingsCache, resolveDefinitionRaw, resolveDefinition, getEffective, } from "./settings.js";
17
17
  export { deriveRequiredPermission, registerDefinitions, set, clear, resetNamespace, renameDefinition, retypeDefinition, reconcileDefinitionDefault, deprecateDefinition, tombstoneDefinition, } from "./write-service.js";
@@ -68,7 +68,13 @@ export interface SettingsRepoPort {
68
68
  principalId: UUID;
69
69
  settingId: UUID;
70
70
  }): Promise<void>;
71
- /** Appends a revision and returns its assigned `seq` (used to stamp the paired value row). */
71
+ /**
72
+ * Appends a revision and returns its assigned `seq` (used to stamp the paired value row).
73
+ * Inside a transaction, serialize revision writers until that enclosing transaction ends:
74
+ * `set`/`clear` compare the stored value's seq AFTER this call, so the lock must cover the
75
+ * comparison and value save too. SQLite's write transaction and Postgres's transaction-scoped
76
+ * ledger lock provide this; memory refuses overlapping transactions.
77
+ */
72
78
  appendRevision(record: Omit<SettingRevisionRecord, "seq">): Promise<number>;
73
79
  listRevisions(required: {
74
80
  settingId: UUID;
@@ -179,8 +179,11 @@ export class InMemorySettingsRepo {
179
179
  nextSeq: this.nextSeq,
180
180
  };
181
181
  this.inTransaction = true;
182
+ // No `finally`: the flag is released on both exits explicitly (V8 coverage counts the
183
+ // unreachable continuation after a catch-block `throw` inside try/finally as a missed branch).
184
+ let result;
182
185
  try {
183
- return await fn();
186
+ result = await fn();
184
187
  }
185
188
  catch (error) {
186
189
  this.definitions = snapshot.definitions;
@@ -189,11 +192,11 @@ export class InMemorySettingsRepo {
189
192
  this.userValues = snapshot.userValues;
190
193
  this.revisions = snapshot.revisions;
191
194
  this.nextSeq = snapshot.nextSeq;
192
- throw error;
193
- }
194
- finally {
195
195
  this.inTransaction = false;
196
+ throw error;
196
197
  }
198
+ this.inTransaction = false;
199
+ return result;
197
200
  }
198
201
  }
199
202
  //# sourceMappingURL=repo.memory.js.map
@@ -142,35 +142,36 @@ export function invalidateWorkspaceSettingsCache(_required, _optional = {}) {
142
142
  * See docs/decisions/DR-003-settings-ledger-invariants.md.
143
143
  */
144
144
  export async function resolveDefinitionRaw(deps, input) {
145
+ const found = await findInCallerOrPlatformPartition(deps.repo, input);
146
+ if (found?.status !== "alias")
147
+ return found ?? null;
148
+ const cursor = aliasTarget(found, input);
149
+ if (!cursor)
150
+ return null;
151
+ const target = await findInCallerOrPlatformPartition(deps.repo, cursor);
152
+ // An alias whose target is itself an alias is a chain deeper than one: absent.
153
+ return target?.status === "alias" ? null : target ?? null;
154
+ }
155
+ /** One alias hop only. A malformed target or a self-alias (the only cycle reachable in one hop)
156
+ * fails closed as absent (`null`) without a second lookup. The hop stays in the caller's workspace. O(1). */
157
+ function aliasTarget(alias, input) {
158
+ if (alias.aliasOfNamespace == null || alias.aliasOfKey == null)
159
+ return null;
160
+ if (alias.aliasOfNamespace === input.namespace && alias.aliasOfKey === input.key)
161
+ return null;
162
+ return { namespace: alias.aliasOfNamespace, key: alias.aliasOfKey, workspaceId: input.workspaceId };
163
+ }
164
+ /** One coordinate lookup: the caller's partition, then the platform (null) partition. O(1) repo calls (at most two). */
165
+ async function findInCallerOrPlatformPartition(repo, cursor) {
145
166
  // Namespace fencing makes `site.*` (non-null workspace_id) and. See docs/decisions/DR-003-settings-ledger-invariants.md.
146
167
  // `core.*`/`theme.*` (null workspace_id) disjoint by construction, so a
147
168
  // caller resolving inside a workspace context may still be asking for a
148
169
  // platform definition. Try the caller's own partition first, then fall
149
170
  // back to the platform (null) partition.
150
- const visited = new Set();
151
- let cursor = input;
152
- for (let depth = 0; depth <= 1; depth++) {
153
- const address = JSON.stringify([cursor.workspaceId, cursor.namespace, cursor.key]);
154
- if (visited.has(address))
155
- return null;
156
- visited.add(address);
157
- const found = (await deps.repo.findActiveDefinition(cursor)) ??
158
- (cursor.workspaceId !== null
159
- ? await deps.repo.findActiveDefinition({ ...cursor, workspaceId: null })
160
- : null);
161
- if (!found)
162
- return null;
163
- if (found.status !== "alias")
164
- return found;
165
- if (depth === 1 || found.aliasOfNamespace == null || found.aliasOfKey == null)
166
- return null;
167
- cursor = {
168
- namespace: found.aliasOfNamespace,
169
- key: found.aliasOfKey,
170
- workspaceId: input.workspaceId,
171
- };
172
- }
173
- return null;
171
+ const own = await repo.findActiveDefinition(cursor);
172
+ if (own || cursor.workspaceId === null)
173
+ return own;
174
+ return repo.findActiveDefinition({ ...cursor, workspaceId: null });
174
175
  }
175
176
  /**
176
177
  * The read-path resolver: typed-absent (`null`) for a tombstoned or
@@ -215,45 +216,38 @@ export async function getEffective(deps, input) {
215
216
  });
216
217
  if (!definition)
217
218
  return null;
218
- const coerce = (value, defVersion) => {
219
- if (defVersion === definition.version)
220
- return value;
221
- const coercer = coercers.get(definition.coercionTag ?? "identity") ?? coercers.get("identity");
222
- return coercer({ value });
223
- };
219
+ const hit = await firstSetLayer(deps.repo, definition.settingId, input.scopeContext);
220
+ if (hit)
221
+ return { value: coerceToVersion(definition, hit.record), sourceLayer: hit.sourceLayer, defVersion: definition.version };
222
+ return { value: definition.defaultValue, sourceLayer: "default", defVersion: definition.version };
223
+ }
224
+ /** A value stored under an older definition version is coerced in memory (no write-back). O(1). */
225
+ function coerceToVersion(definition, record) {
226
+ if (record.defVersion === definition.version)
227
+ return record.valueJson;
228
+ const coercer = coercers.get(definition.coercionTag ?? "identity") ?? coercers.get("identity");
229
+ return coercer({ value: record.valueJson });
230
+ }
231
+ /**
232
+ * Precedence `user ?? workspace ?? global`; a `cleared` row is absent at its layer. The user layer
233
+ * needs both a workspace and a principal; the workspace layer needs a workspace.
234
+ * O(1): up to three repo reads, in precedence order, stopping at the first `set` row.
235
+ */
236
+ async function firstSetLayer(repo, settingId, scope) {
224
237
  // Every layer read below goes straight to the repo. See this module's cache header for why there
225
238
  // is no per-layer cache wrapping these three calls.
226
- if (input.scopeContext.workspaceId && input.scopeContext.principalId) {
227
- const workspaceId = input.scopeContext.workspaceId;
228
- const principalId = input.scopeContext.principalId;
229
- const userValue = await deps.repo.getUserValue({ workspaceId, principalId, settingId: definition.settingId });
230
- if (userValue && userValue.state === "set") {
231
- return {
232
- value: coerce(userValue.valueJson, userValue.defVersion),
233
- sourceLayer: "user",
234
- defVersion: definition.version,
235
- };
236
- }
239
+ const { workspaceId, principalId } = scope;
240
+ if (workspaceId && principalId) {
241
+ const user = await repo.getUserValue({ workspaceId, principalId, settingId });
242
+ if (user?.state === "set")
243
+ return { record: user, sourceLayer: "user" };
237
244
  }
238
- if (input.scopeContext.workspaceId) {
239
- const workspaceId = input.scopeContext.workspaceId;
240
- const workspaceValue = await deps.repo.getWorkspaceValue({ workspaceId, settingId: definition.settingId });
241
- if (workspaceValue && workspaceValue.state === "set") {
242
- return {
243
- value: coerce(workspaceValue.valueJson, workspaceValue.defVersion),
244
- sourceLayer: "workspace",
245
- defVersion: definition.version,
246
- };
247
- }
245
+ if (workspaceId) {
246
+ const workspace = await repo.getWorkspaceValue({ workspaceId, settingId });
247
+ if (workspace?.state === "set")
248
+ return { record: workspace, sourceLayer: "workspace" };
248
249
  }
249
- const globalValue = await deps.repo.getGlobalValue(definition.settingId);
250
- if (globalValue && globalValue.state === "set") {
251
- return {
252
- value: coerce(globalValue.valueJson, globalValue.defVersion),
253
- sourceLayer: "global",
254
- defVersion: definition.version,
255
- };
256
- }
257
- return { value: definition.defaultValue, sourceLayer: "default", defVersion: definition.version };
250
+ const global = await repo.getGlobalValue(settingId);
251
+ return global?.state === "set" ? { record: global, sourceLayer: "global" } : null;
258
252
  }
259
253
  //# sourceMappingURL=settings.js.map
@@ -1,5 +1,4 @@
1
- import type { IdGenerator } from "../primitives/index.js";
2
- import type { Clock } from "../primitives/index.js";
1
+ import type { Clock, IdGenerator } from "../primitives/index.js";
3
2
  /**
4
3
  * @file Settings tools: three reads and three bounded value writes. Bulk reset and definition
5
4
  * registration stay unwired. The generic tools reuse write-service authorization/validation and
@@ -12,7 +11,7 @@ import type { LegacyToolAuthorizeFn as AuthorizeFn } from "../tool-permissions.j
12
11
  import { type SettingsWriteConfirmation } from "./agent-value-write-tools.js";
13
12
  import { type AgentSettingWriteRule } from "./agent-write-denylist.js";
14
13
  import { type DerivedRiskByToolId } from "../registration-kit.js";
15
- import type { ToolExecutionOptions, ToolRegistration } from "../tool-registry.js";
14
+ import { type ToolExecutionOptions, type ToolRegistration } from "../tool-registry.js";
16
15
  import type { SettingsPrincipalLookupPort } from "./principal-lookup.js";
17
16
  import type { SettingsRepoPort } from "./ports.js";
18
17
  import { set as setSettingValue } from "./write-service.js";