@happyvertical/smrt-fields 0.40.57

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.
@@ -0,0 +1,347 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "generatedAt": "1970-01-01T00:00:00.000Z",
4
+ "packageName": "@happyvertical/smrt-fields",
5
+ "packageVersion": "0.40.57",
6
+ "sourceManifestPath": "dist/manifest.json",
7
+ "agentDocPath": "AGENTS.md",
8
+ "sourceHashes": {
9
+ "manifest": "bbe9ab11eab7eb6f158f406f86999254fab6b92cc866fd7082d8bf5d674be2c6",
10
+ "packageJson": "0a79f8eeb56359dff3d0a635fa47b4aa06857311e23227eeaada315fbd7063b0",
11
+ "agents": "efe605cdabdde966a22fc43d94af0275bfb1ce5e2027b52809c69837e3d5dc0c"
12
+ },
13
+ "exports": [
14
+ ".",
15
+ "./manifest",
16
+ "./manifest.json"
17
+ ],
18
+ "dependencies": {
19
+ "@happyvertical/smrt-core": "workspace:*",
20
+ "@happyvertical/smrt-tenancy": "workspace:*",
21
+ "@happyvertical/sql": "catalog:",
22
+ "@happyvertical/smrt-cli": "workspace:*",
23
+ "@happyvertical/smrt-users": "workspace:*",
24
+ "@happyvertical/smrt-vitest": "workspace:*",
25
+ "@types/node": "24.13.2",
26
+ "typescript": "5.9.3",
27
+ "vite": "8.1.4",
28
+ "vitest": "4.1.10"
29
+ },
30
+ "smrtDependencies": [
31
+ "@happyvertical/smrt-cli",
32
+ "@happyvertical/smrt-core",
33
+ "@happyvertical/smrt-tenancy",
34
+ "@happyvertical/smrt-users",
35
+ "@happyvertical/smrt-vitest"
36
+ ],
37
+ "sdkDependencies": [
38
+ "@happyvertical/sql"
39
+ ],
40
+ "tags": [],
41
+ "risks": [],
42
+ "objects": [
43
+ {
44
+ "name": "FieldPolicyCollection",
45
+ "qualifiedName": "@happyvertical/smrt-fields:FieldPolicyCollection",
46
+ "collection": "fieldpolicies",
47
+ "tableName": "_smrt_field_policies",
48
+ "packageName": "@happyvertical/smrt-fields",
49
+ "extends": "SmrtCollection",
50
+ "fields": [],
51
+ "relationships": [],
52
+ "methods": [
53
+ "getAppRows",
54
+ "getTenantRows",
55
+ "getUserRows",
56
+ "resolveBatch"
57
+ ],
58
+ "surfaces": [
59
+ {
60
+ "kind": "api",
61
+ "name": "fieldpolicies.create",
62
+ "operation": "create",
63
+ "objectName": "@happyvertical/smrt-fields:FieldPolicyCollection",
64
+ "path": "/fieldpolicies",
65
+ "method": "POST"
66
+ },
67
+ {
68
+ "kind": "api",
69
+ "name": "fieldpolicies.update",
70
+ "operation": "update",
71
+ "objectName": "@happyvertical/smrt-fields:FieldPolicyCollection",
72
+ "path": "/fieldpolicies/[id]",
73
+ "method": "PATCH"
74
+ },
75
+ {
76
+ "kind": "api",
77
+ "name": "fieldpolicies.delete",
78
+ "operation": "delete",
79
+ "objectName": "@happyvertical/smrt-fields:FieldPolicyCollection",
80
+ "path": "/fieldpolicies/[id]",
81
+ "method": "DELETE"
82
+ },
83
+ {
84
+ "kind": "api",
85
+ "name": "fieldpolicies.resolveBatch",
86
+ "operation": "resolveBatch",
87
+ "objectName": "@happyvertical/smrt-fields:FieldPolicyCollection",
88
+ "path": "/fieldpolicies/resolveBatch",
89
+ "method": "POST"
90
+ }
91
+ ],
92
+ "relationshipFeatures": [
93
+ "uuidColumns"
94
+ ],
95
+ "tags": [],
96
+ "risks": []
97
+ },
98
+ {
99
+ "name": "FieldPolicy",
100
+ "qualifiedName": "@happyvertical/smrt-fields:FieldPolicy",
101
+ "collection": "fieldpolicies",
102
+ "tableName": "_smrt_field_policies",
103
+ "packageName": "@happyvertical/smrt-fields",
104
+ "extends": "SmrtObject",
105
+ "fields": [
106
+ {
107
+ "name": "objectRef",
108
+ "type": "text",
109
+ "required": true,
110
+ "columnType": "TEXT"
111
+ },
112
+ {
113
+ "name": "fieldName",
114
+ "type": "text",
115
+ "required": true,
116
+ "columnType": "TEXT"
117
+ },
118
+ {
119
+ "name": "scopeType",
120
+ "type": "text",
121
+ "required": true,
122
+ "columnType": "TEXT"
123
+ },
124
+ {
125
+ "name": "tenantId",
126
+ "type": "text",
127
+ "required": false,
128
+ "columnType": "UUID"
129
+ },
130
+ {
131
+ "name": "userId",
132
+ "type": "crossPackageRef",
133
+ "required": false,
134
+ "related": "@happyvertical/smrt-users:User",
135
+ "columnType": "UUID"
136
+ },
137
+ {
138
+ "name": "scopeKey",
139
+ "type": "text",
140
+ "required": true,
141
+ "columnType": "TEXT"
142
+ },
143
+ {
144
+ "name": "defaultValue",
145
+ "type": "text",
146
+ "required": false,
147
+ "columnType": "TEXT"
148
+ },
149
+ {
150
+ "name": "visibility",
151
+ "type": "text",
152
+ "required": false,
153
+ "columnType": "TEXT"
154
+ },
155
+ {
156
+ "name": "help",
157
+ "type": "text",
158
+ "required": false,
159
+ "columnType": "TEXT"
160
+ },
161
+ {
162
+ "name": "label",
163
+ "type": "text",
164
+ "required": false,
165
+ "columnType": "TEXT"
166
+ },
167
+ {
168
+ "name": "displayOrder",
169
+ "type": "integer",
170
+ "required": false,
171
+ "columnType": "INTEGER"
172
+ },
173
+ {
174
+ "name": "locked",
175
+ "type": "boolean",
176
+ "required": false,
177
+ "columnType": "BOOLEAN"
178
+ },
179
+ {
180
+ "name": "updatedBy",
181
+ "type": "crossPackageRef",
182
+ "required": false,
183
+ "related": "@happyvertical/smrt-users:User",
184
+ "columnType": "UUID"
185
+ }
186
+ ],
187
+ "relationships": [
188
+ {
189
+ "name": "userId",
190
+ "type": "crossPackageRef",
191
+ "required": false,
192
+ "related": "@happyvertical/smrt-users:User",
193
+ "columnType": "UUID"
194
+ },
195
+ {
196
+ "name": "updatedBy",
197
+ "type": "crossPackageRef",
198
+ "required": false,
199
+ "related": "@happyvertical/smrt-users:User",
200
+ "columnType": "UUID"
201
+ }
202
+ ],
203
+ "methods": [
204
+ "delete",
205
+ "getDefaultValue",
206
+ "save",
207
+ "setDefaultValue"
208
+ ],
209
+ "surfaces": [
210
+ {
211
+ "kind": "api",
212
+ "name": "fieldpolicies.create",
213
+ "operation": "create",
214
+ "objectName": "@happyvertical/smrt-fields:FieldPolicy",
215
+ "path": "/fieldpolicies",
216
+ "method": "POST"
217
+ },
218
+ {
219
+ "kind": "api",
220
+ "name": "fieldpolicies.update",
221
+ "operation": "update",
222
+ "objectName": "@happyvertical/smrt-fields:FieldPolicy",
223
+ "path": "/fieldpolicies/[id]",
224
+ "method": "PATCH"
225
+ },
226
+ {
227
+ "kind": "api",
228
+ "name": "fieldpolicies.delete",
229
+ "operation": "delete",
230
+ "objectName": "@happyvertical/smrt-fields:FieldPolicy",
231
+ "path": "/fieldpolicies/[id]",
232
+ "method": "DELETE"
233
+ },
234
+ {
235
+ "kind": "cli",
236
+ "name": "fieldpolicy_create",
237
+ "operation": "create",
238
+ "objectName": "@happyvertical/smrt-fields:FieldPolicy"
239
+ },
240
+ {
241
+ "kind": "cli",
242
+ "name": "fieldpolicy_update",
243
+ "operation": "update",
244
+ "objectName": "@happyvertical/smrt-fields:FieldPolicy"
245
+ },
246
+ {
247
+ "kind": "cli",
248
+ "name": "fieldpolicy_delete",
249
+ "operation": "delete",
250
+ "objectName": "@happyvertical/smrt-fields:FieldPolicy"
251
+ }
252
+ ],
253
+ "relationshipFeatures": [
254
+ "crossPackageRef",
255
+ "uuidColumns"
256
+ ],
257
+ "tags": [],
258
+ "risks": []
259
+ }
260
+ ],
261
+ "surfaces": [
262
+ {
263
+ "kind": "api",
264
+ "name": "fieldpolicies.create",
265
+ "operation": "create",
266
+ "objectName": "@happyvertical/smrt-fields:FieldPolicyCollection",
267
+ "path": "/fieldpolicies",
268
+ "method": "POST"
269
+ },
270
+ {
271
+ "kind": "api",
272
+ "name": "fieldpolicies.update",
273
+ "operation": "update",
274
+ "objectName": "@happyvertical/smrt-fields:FieldPolicyCollection",
275
+ "path": "/fieldpolicies/[id]",
276
+ "method": "PATCH"
277
+ },
278
+ {
279
+ "kind": "api",
280
+ "name": "fieldpolicies.delete",
281
+ "operation": "delete",
282
+ "objectName": "@happyvertical/smrt-fields:FieldPolicyCollection",
283
+ "path": "/fieldpolicies/[id]",
284
+ "method": "DELETE"
285
+ },
286
+ {
287
+ "kind": "api",
288
+ "name": "fieldpolicies.resolveBatch",
289
+ "operation": "resolveBatch",
290
+ "objectName": "@happyvertical/smrt-fields:FieldPolicyCollection",
291
+ "path": "/fieldpolicies/resolveBatch",
292
+ "method": "POST"
293
+ },
294
+ {
295
+ "kind": "api",
296
+ "name": "fieldpolicies.create",
297
+ "operation": "create",
298
+ "objectName": "@happyvertical/smrt-fields:FieldPolicy",
299
+ "path": "/fieldpolicies",
300
+ "method": "POST"
301
+ },
302
+ {
303
+ "kind": "api",
304
+ "name": "fieldpolicies.update",
305
+ "operation": "update",
306
+ "objectName": "@happyvertical/smrt-fields:FieldPolicy",
307
+ "path": "/fieldpolicies/[id]",
308
+ "method": "PATCH"
309
+ },
310
+ {
311
+ "kind": "api",
312
+ "name": "fieldpolicies.delete",
313
+ "operation": "delete",
314
+ "objectName": "@happyvertical/smrt-fields:FieldPolicy",
315
+ "path": "/fieldpolicies/[id]",
316
+ "method": "DELETE"
317
+ },
318
+ {
319
+ "kind": "cli",
320
+ "name": "fieldpolicy_create",
321
+ "operation": "create",
322
+ "objectName": "@happyvertical/smrt-fields:FieldPolicy"
323
+ },
324
+ {
325
+ "kind": "cli",
326
+ "name": "fieldpolicy_update",
327
+ "operation": "update",
328
+ "objectName": "@happyvertical/smrt-fields:FieldPolicy"
329
+ },
330
+ {
331
+ "kind": "cli",
332
+ "name": "fieldpolicy_delete",
333
+ "operation": "delete",
334
+ "objectName": "@happyvertical/smrt-fields:FieldPolicy"
335
+ }
336
+ ],
337
+ "prompts": [],
338
+ "relationshipsV2": {
339
+ "foreignKeyFields": 0,
340
+ "crossPackageRefFields": 2,
341
+ "junctionCollections": 0,
342
+ "hierarchicalObjects": 0,
343
+ "polymorphicAssociations": 0,
344
+ "uuidColumns": 5
345
+ },
346
+ "agentDoc": "# smrt-fields\n\nLayered field policy store and resolution engine (epic #2045). Personalizes\nper-field `{defaultValue, visibility, help, label, order, locked}` for any\n`@smrt()` object at app, tenant, and user scope over the code seed.\n\n## Core pieces\n\n- `FieldPolicy` (`_smrt_field_policies` table) — sparse override rows keyed\n `(objectRef, fieldName, scopeType, scopeKey)`; NULL column = inherit from\n the lower layer, reset = row DELETE (later lower-layer changes flow through)\n- `FieldPolicyCollection` — write surface plus the `resolveBatch` custom\n collection-scoped action (`POST /<collection>/resolve`). NO generated\n surface exposes read verbs: API `list`/`get` on this non-tenant-scoped\n model would enumerate every tenant's and user's rows; the model's CLI is\n writes-only (the generated CLI invokes over HTTP, and the cli↔api\n coherence gate rejects CLI entries without API routes); the runtime\n CLI/MCP surfaces are closed by the collection config (ContentContributions\n precedent). Reads go through `resolveBatch` (context-scoped) or the\n server-side resolver. Keep the api include lists in lockstep: a decorated\n collection's config is the RUNTIME registry authority for its item class,\n while build-time generation reads each manifest object's own config. Both\n transports dispatch the action: generated SvelteKit routes natively, and\n core's runtime `APIGenerator` via its decorator-route dispatch\n (single-segment collection-scoped paths; multi-segment custom paths remain\n SvelteKit-only).\n- `resolveFieldPolicy(objectRef, { tenantId, userId, db })` — merged policy\n- `resolveFieldPolicyExplained(...)` — merged policy plus ordered per-layer\n contributions (code/app/tenant/user) for gear/admin UIs (#2049/#2050)\n\n## Resolution layers (priority low → high)\n\n1. Code seed — manifest field defaults, `description` as help, `_meta.ui`\n hints (#2046: `basic`/`group`/`order`/`locked`; cold-start rule: no\n `basic` markers ⇒ everything basic, any marker ⇒ unmarked fields advanced)\n2. App rows — `scopeType: 'app'`, `tenantId`/`userId` null\n3. Tenant rows — hierarchy walk root → leaf via an injected\n `tenantHierarchyLoader` (smrt-features shape); the default loader\n dynamic-imports `@happyvertical/smrt-users` (missing-package failures —\n `ERR_MODULE_NOT_FOUND` / \"Cannot find package\" across the cause chain —\n fall back to a flat single-tenant chain). A node that breaks permission\n inheritance discards every earlier tenant contribution (chain-structural),\n so only the suffix from the LAST break participates — in merging AND in\n the explained layers, which therefore replay to the merged result\n4. User rows — keyed by `userId` alone (preferences follow the user); both\n defaults AND visibility resolve through this tier\n\n## Invariants\n\n- **Scope shape**: app ⇒ `tenantId`+`userId` null; tenant ⇒ only `tenantId`;\n user ⇒ only `userId`. `scopeKey` (`userId ?? tenantId ?? '__app__'`) exists\n ONLY to keep the `conflictColumns` unique index total — never read it for\n scoping logic.\n- **Manifest as definition registry**: writes validate against the live\n `ObjectRegistry` (never checked-in manifest.json artifacts): unknown\n objectRef/fieldName rejected; defaults type-checked against the field type;\n system fields, relationship pseudo-fields, and STI meta storage fields are\n not policy-addressable (the resolver excludes them, so rows would silently\n never apply).\n- **Security rail**: defaults are refused on `transient`, `sensitive`, and\n `readPermission`-gated fields (both top-level and `_meta` flags checked).\n Reference-field (`foreignKey`/`crossPackageRef`) defaults must be UUID\n strings unless the field declares `idType: 'text'` — the columns are\n native UUID on PostgreSQL/DuckDB and a non-UUID default would fail at\n insert time. `resolveBatch` responses omit sensitive/read-permission-gated\n fields for every caller (fail closed) and transient fields\n (client-emission parity).\n- **Required-field invariant**: demoting a required field to\n advanced/hidden requires a usable resolved default (not null/empty) —\n enforced at write time AND re-enforced at resolution (a required field with\n no usable default always resolves `basic`, flagged `visibilityForced`).\n- **Org lock**: `locked` may be set on app/tenant rows only; while the\n code/app/tenant tiers resolve locked, user-scope writes are rejected and\n existing user rows are skipped at resolution.\n- **Write-time org checks reuse the resolver**: the required-demotion default\n and the user-write lock are computed by `resolveFieldPolicy` over the org\n tiers (default hierarchy loader), so cascading ancestor-tenant defaults and\n locks are honored at save time too. On updates the resolver sees the row's\n persisted version (no self-exclusion), so the resolver-side safety net\n remains the authoritative enforcement at read time.\n- **Isolation — a MISSING identity component DENIES, it never skips.** This\n is the package rule; both the write guard\n (`FieldPolicy.assertScopeOwnedByAmbientContext`) and the read guard\n (`assertResolutionAllowedInContext`) obey it. A non-bypass tenant context\n may only resolve/write its own tenant, and the user tier only for its own\n user id — a context carrying permissions but NO `userId` (no `resolveUserId`\n hook configured: API-key auth, service principals, background jobs, a bare\n `withTenant({ tenantId })`) may not touch the user tier at all. Skipping\n that check instead of denying it was a live ownership bypass: user rows are\n `tenantId: null` by design, so nothing else contains such a write. App-scope\n writes inside a tenant context require super-admin bypass. With NO ambient\n identity at all (tenancy ALS never entered), only app-scope writes/deletes\n are accepted; context-LESS *reads* stay allowed because\n `resolveFieldPolicy` is a trusted server-side API. Residual: ALS-less\n deployments can still write APP rows with any authenticated principal until\n the #2049 permission layer adds `fields:policy:manage`. `save()`/`delete()`\n on an existing row additionally authorize against the row's PERSISTED\n scope, looked up by primary key AND — because a generated create always\n mints a fresh UUID while the `conflictColumns` upsert still replaces the\n occupant — by NATURAL key. `resolveBatch` takes identity exclusively from\n the ambient context; the request body cannot select another tenant or user.\n- **Scope attribution**: inside an ambient context the model DERIVES a\n missing `tenantId` (tenant rows) or `userId` (user rows) from that context,\n and always stamps `updatedBy` from it. Core's mass-assignment guard treats\n `tenantId` as server-managed and strips it from every generated write body\n while `FieldPolicy` is deliberately not `@TenantScoped`, so without this the\n org tier is write-dead over REST/SvelteKit (scope-shape validation throws).\n Deriving grants nothing — the ownership guard already pinned the value to\n the ambient one. An explicit value is never overwritten, so a super-admin\n bypass caller writing ANOTHER tenant's row must go through a server-side\n model call; the generated routes still strip it.\n\n## Caching and invalidation\n\n- Resolver results cached per\n `(dbNamespace, objectRef, tenantId, userId, hierarchyLoader)` with a 30s TTL\n (`cache.ts` mirrors smrt-prompts' `getDbNamespace`). The loader identity is\n part of the key because an injected `tenantHierarchyLoader` yields a\n different ancestor chain — and so different defaults/locks — for the same\n `(db, objectRef, tenant, user)`; it goes LAST so the `(db, objectRef)`\n prefix scan still invalidates every loader's entries.\n- `FieldPolicy.save()`/`.delete()` invalidate ALL entries for the row's\n `(db, objectRef)` — coarser than prompts because tenant hierarchy makes a\n parent-tenant row affect every descendant's resolution.\n- `_smrt_field_policies` rows do NOT ride the client change feed: core's\n change-feed writer deliberately skips `_smrt_`-prefixed system tables, and\n the emit side is private. Live client invalidation is a core-side decision;\n do not add custom push/emit paths here.\n\n## Gotchas\n\n- Defaults have TWO explicit constructor channels, never sniffed:\n `defaultValue` is ALREADY JSON-encoded (the wire contract — generated write\n routes hand the request body straight to the constructor, and #2049's gear\n posts `JSON.stringify(draft.defaultValue)`), while `defaultValueRaw` is a\n plain value that is always serialized, strings included. Passing both throws.\n One option cannot carry both meanings: `'\"TBD\"'` and `'TBD'` are\n indistinguishable, so `{ defaultValue: 'Net 30' }` is a parse error that\n names `defaultValueRaw` in its message. `setDefaultValue()` is the\n method-level plain channel.\n- The sort-order column is `displayOrder` (resolved output exposes `order`):\n a column literally named `order` is an SQL keyword the runtime INSERT path\n does not quote.\n- `FieldPolicy` deliberately has NO class-level `@TenantScoped` (the\n prompts/features precedent): resolution legitimately reads app rows and\n ancestor-tenant rows, which the tenancy interceptor would block. Isolation\n is enforced at the resolver/save boundaries instead.\n- Identity changes (objectRef/fieldName/scope) on a persisted row go through\n delete-then-insert (transactional when the driver supports it) because the\n natural-key upsert would otherwise collide with the primary key.\n- `FieldPolicyCollection` MUST repeat FieldPolicy's `conflictColumns`. A\n decorated collection emits its OWN manifest schema for the item's table, and\n without the natural key that schema falls back to SmrtObject's default unique\n `(slug, context)` index; manifest-driven migrations aggregate both onto\n `_smrt_field_policies`, where the stray index rejects legitimate layered rows\n (all policy rows have NULL slug/context). The runtime registry cannot catch\n this — `getAllSchemas()` is keyed by TABLE name, so the two schemas collapse\n into one entry. Pinned against the generated manifest in\n `generated-surfaces.test.ts`.\n- `withSystemContext()` does NOT unlock tenant/user-scope writes:\n `getCurrentTenant()` returns undefined inside it, so the context-absent rule\n rejects those tiers before any bypass check. Seeds and migrations that must\n write org/user rows use a `superAdminBypass` context instead.\n- The MODEL's `cli`/`mcp` decorator config is dead at runtime: the registry\n re-registers the item slot with the COLLECTION's config wholesale, so\n `FieldPolicyCollection`'s `cli: false, mcp: false` is what actually closes\n those surfaces. Keep both in lockstep anyway — build-time generation reads\n the model's own config. Related: `ObjectRegistry.getTableName(\n 'FieldPolicyCollection')` resolves to the UNPREFIXED collection fallback\n `field_policies`; persistence uses the item class\n (`_smrt_field_policies`), and both are pinned in\n `generated-surfaces.test.ts`.\n\n## Related\n\n- `@happyvertical/smrt-prompts` / `smrt-languages` / `smrt-features` — the\n same architecture family (override rows + layered resolver + TTL cache)\n- Core `FieldUIHints` (#2046) — the `@field({ ui })` code seed this package\n resolves over\n"
347
+ }
@@ -0,0 +1,203 @@
1
+ import { SmrtClassOptions } from '@happyvertical/smrt-core';
2
+ import { SmrtObjectOptions } from '@happyvertical/smrt-core';
3
+
4
+ /**
5
+ * `scopeKey` value for app-scope rows. `scopeKey` exists ONLY so the
6
+ * `conflictColumns` unique index stays total while `tenantId`/`userId` are
7
+ * nullable (nullable columns would allow duplicate NULL rows) — the same
8
+ * trick as `PromptOverride.context`.
9
+ */
10
+ export declare const APP_FIELD_POLICY_SCOPE_KEY = "__app__";
11
+
12
+ /**
13
+ * Explain variant: merged result plus the ordered per-layer contributions for
14
+ * each field, so admin/gear UIs (#2049/#2050) never re-derive precedence.
15
+ * Layers are listed in application order (code, app, tenant chain root → leaf,
16
+ * user) and contain ONLY contributions that survive into the merged result:
17
+ * tenant ancestors discarded by a permission-inheritance break and user rows
18
+ * suppressed by an effective org lock are omitted, so sequentially replaying
19
+ * the listed deltas reproduces the merged policy.
20
+ */
21
+ export declare interface ExplainedObjectFieldPolicy extends ResolvedObjectFieldPolicy {
22
+ layers: Record<string, FieldPolicyLayerContribution[]>;
23
+ }
24
+
25
+ export declare const FIELD_POLICY_SCOPE_TYPES: readonly FieldPolicyScopeType[];
26
+
27
+ export declare const FIELD_POLICY_VISIBILITIES: readonly FieldPolicyVisibility[];
28
+
29
+ /** Result shape of the batch resolve action (`FieldPolicyCollection.resolveBatch`). */
30
+ export declare interface FieldPolicyBatchResult {
31
+ policies: Record<string, ResolvedObjectFieldPolicy>;
32
+ }
33
+
34
+ /**
35
+ * The sparse contribution of one layer (code seed or one stored row).
36
+ *
37
+ * `default` is boxed so an explicit JSON `null` default (meaning "default to
38
+ * null") stays distinguishable from "this layer contributes no default".
39
+ */
40
+ export declare interface FieldPolicyDelta {
41
+ default?: {
42
+ value: unknown;
43
+ };
44
+ visibility?: FieldPolicyVisibility;
45
+ help?: string;
46
+ label?: string;
47
+ order?: number;
48
+ locked?: boolean;
49
+ }
50
+
51
+ /** One layer's contribution to a field's resolved policy (explain variant). */
52
+ export declare interface FieldPolicyLayerContribution {
53
+ layer: 'code' | 'app' | 'tenant' | 'user';
54
+ /** Chain node id for `tenant` layers (root → leaf order). */
55
+ tenantId?: string;
56
+ /** User id for the `user` layer. */
57
+ userId?: string;
58
+ delta: FieldPolicyDelta;
59
+ }
60
+
61
+ export declare interface FieldPolicyOptions extends SmrtObjectOptions {
62
+ objectRef?: string;
63
+ fieldName?: string;
64
+ scopeType?: FieldPolicyScopeType;
65
+ tenantId?: string | null;
66
+ userId?: string | null;
67
+ /**
68
+ * ENCODED channel: an already-JSON-encoded default, exactly as the column
69
+ * stores it (`'"Net 30"'`, `'42'`, `'null'`), or `null` to inherit.
70
+ *
71
+ * This is the wire contract: the generated write routes hand the request
72
+ * body straight to the constructor, and the #2049/#2050 gear posts
73
+ * `JSON.stringify(draft.defaultValue)`. It must therefore keep meaning
74
+ * "already encoded" — auto-serializing here would double-encode every gear
75
+ * write.
76
+ *
77
+ * Use {@link FieldPolicyOptions.defaultValueRaw} for a plain value. The two
78
+ * are mutually exclusive: `'"TBD"'` and `'TBD'` are indistinguishable once
79
+ * a single option carries both meanings, so the channel must be explicit
80
+ * rather than sniffed.
81
+ */
82
+ defaultValue?: string | null;
83
+ /**
84
+ * PLAIN channel: any value, always serialized — strings included. The
85
+ * constructor-option twin of {@link FieldPolicy.setDefaultValue}, so
86
+ * `{ defaultValueRaw: 'Net 30' }` stores `'"Net 30"'` rather than the
87
+ * unparseable literal `Net 30`.
88
+ */
89
+ defaultValueRaw?: unknown;
90
+ visibility?: FieldPolicyVisibility | null;
91
+ help?: string | null;
92
+ label?: string | null;
93
+ displayOrder?: number | null;
94
+ locked?: boolean | null;
95
+ updatedBy?: string | null;
96
+ }
97
+
98
+ /**
99
+ * Scope tier a {@link FieldPolicy} row belongs to.
100
+ *
101
+ * Resolution precedence is code seed → `app` → `tenant` (hierarchy walk,
102
+ * root → leaf) → `user`. Unlike smrt-features (which ships only
103
+ * `global`/`tenant`), the user tier is implemented end to end here — both
104
+ * defaults and visibility resolve through it.
105
+ */
106
+ export declare type FieldPolicyScopeType = 'app' | 'tenant' | 'user';
107
+
108
+ /**
109
+ * Loader-function DI seam (NOT a container registration): the resolver calls
110
+ * it lazily and treats `null` as "no hierarchy available" (flat-tenant
111
+ * fallback). The default loader dynamic-imports `@happyvertical/smrt-users`
112
+ * and returns `null` when it is not installed.
113
+ */
114
+ export declare type FieldPolicyTenantHierarchyLoader = (options: SmrtClassOptions) => Promise<FieldPolicyTenantHierarchyProvider | null>;
115
+
116
+ /** Provider returning the root → leaf tenant chain (mirrors smrt-features). */
117
+ export declare interface FieldPolicyTenantHierarchyProvider {
118
+ getChain(tenantId: string): Promise<FieldPolicyTenantNode[]>;
119
+ }
120
+
121
+ /** Minimal tenant node consumed by the hierarchy walk (mirrors smrt-features). */
122
+ export declare interface FieldPolicyTenantNode {
123
+ id: string;
124
+ inheritPermissions: boolean;
125
+ cascadePermissions: boolean;
126
+ }
127
+
128
+ export declare interface FieldPolicyUsersModule {
129
+ TenantCollection: {
130
+ create(options: SmrtClassOptions): Promise<{
131
+ get(criteria: {
132
+ id: string;
133
+ }): Promise<FieldPolicyUsersTenantRecord | null>;
134
+ getAncestorsFromRoot(tenantId: string): Promise<FieldPolicyUsersTenantRecord[]>;
135
+ }>;
136
+ };
137
+ }
138
+
139
+ /**
140
+ * Minimal structural shape of the tenant surface loaded from
141
+ * `@happyvertical/smrt-users` by the default hierarchy loader. Only the
142
+ * permission-cascade fields consumed by the chain walk are modeled.
143
+ */
144
+ export declare interface FieldPolicyUsersTenantRecord {
145
+ id: string;
146
+ inheritPermissions?: boolean;
147
+ cascadePermissions?: boolean;
148
+ }
149
+
150
+ /**
151
+ * Visibility tier for a field in generated/consuming form UIs.
152
+ *
153
+ * - `basic`: shown before the advanced disclosure
154
+ * - `advanced`: shown behind the advanced disclosure
155
+ * - `hidden`: not rendered (requires the field to be optional or have a
156
+ * resolved default — the required-field invariant)
157
+ */
158
+ export declare type FieldPolicyVisibility = 'basic' | 'advanced' | 'hidden';
159
+
160
+ /** Fully merged policy for a single field. */
161
+ export declare interface ResolvedFieldPolicy {
162
+ fieldName: string;
163
+ /** True when any layer resolved a default (including an explicit null). */
164
+ hasDefault: boolean;
165
+ /** Parsed default value; `undefined` when {@link hasDefault} is false. */
166
+ defaultValue: unknown;
167
+ visibility: FieldPolicyVisibility;
168
+ help: string | null;
169
+ label: string | null;
170
+ order: number | null;
171
+ /** Grouping key — code-seed only (`ui.group`), not overridable by rows. */
172
+ group: string | null;
173
+ locked: boolean;
174
+ /** Mirror of the manifest required flag (nullable fields are optional). */
175
+ required: boolean;
176
+ /**
177
+ * True when the resolver forced `basic` visibility because the field is
178
+ * required and no usable default resolved (the resolver-side safety net for
179
+ * the required-field invariant).
180
+ */
181
+ visibilityForced?: boolean;
182
+ }
183
+
184
+ /** Merged policy for every field of one object. */
185
+ export declare interface ResolvedObjectFieldPolicy {
186
+ objectRef: string;
187
+ fields: Record<string, ResolvedFieldPolicy>;
188
+ }
189
+
190
+ export declare interface ResolveFieldPolicyOptions {
191
+ tenantId?: string | null;
192
+ userId?: string | null;
193
+ /**
194
+ * Database holding `_smrt_field_policies`. Without it, only the code seed
195
+ * resolves (stored layers are skipped) — the smrt-prompts precedent.
196
+ */
197
+ db?: SmrtClassOptions['db'];
198
+ tenantHierarchyLoader?: FieldPolicyTenantHierarchyLoader;
199
+ }
200
+
201
+ export { SmrtClassOptions }
202
+
203
+ export { }
package/dist/types.js ADDED
@@ -0,0 +1,16 @@
1
+ //#region src/types.ts
2
+ var APP_FIELD_POLICY_SCOPE_KEY = "__app__";
3
+ var FIELD_POLICY_SCOPE_TYPES = [
4
+ "app",
5
+ "tenant",
6
+ "user"
7
+ ];
8
+ var FIELD_POLICY_VISIBILITIES = [
9
+ "basic",
10
+ "advanced",
11
+ "hidden"
12
+ ];
13
+ //#endregion
14
+ export { APP_FIELD_POLICY_SCOPE_KEY, FIELD_POLICY_SCOPE_TYPES, FIELD_POLICY_VISIBILITIES };
15
+
16
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","names":[],"sources":["../src/types.ts"],"sourcesContent":["import type {\n SmrtClassOptions,\n SmrtObjectOptions,\n} from '@happyvertical/smrt-core';\n\n/**\n * Scope tier a {@link FieldPolicy} row belongs to.\n *\n * Resolution precedence is code seed → `app` → `tenant` (hierarchy walk,\n * root → leaf) → `user`. Unlike smrt-features (which ships only\n * `global`/`tenant`), the user tier is implemented end to end here — both\n * defaults and visibility resolve through it.\n */\nexport type FieldPolicyScopeType = 'app' | 'tenant' | 'user';\n\n/**\n * Visibility tier for a field in generated/consuming form UIs.\n *\n * - `basic`: shown before the advanced disclosure\n * - `advanced`: shown behind the advanced disclosure\n * - `hidden`: not rendered (requires the field to be optional or have a\n * resolved default — the required-field invariant)\n */\nexport type FieldPolicyVisibility = 'basic' | 'advanced' | 'hidden';\n\n/**\n * `scopeKey` value for app-scope rows. `scopeKey` exists ONLY so the\n * `conflictColumns` unique index stays total while `tenantId`/`userId` are\n * nullable (nullable columns would allow duplicate NULL rows) — the same\n * trick as `PromptOverride.context`.\n */\nexport const APP_FIELD_POLICY_SCOPE_KEY = '__app__';\n\nexport const FIELD_POLICY_SCOPE_TYPES: readonly FieldPolicyScopeType[] = [\n 'app',\n 'tenant',\n 'user',\n];\n\nexport const FIELD_POLICY_VISIBILITIES: readonly FieldPolicyVisibility[] = [\n 'basic',\n 'advanced',\n 'hidden',\n];\n\nexport interface FieldPolicyOptions extends SmrtObjectOptions {\n objectRef?: string;\n fieldName?: string;\n scopeType?: FieldPolicyScopeType;\n tenantId?: string | null;\n userId?: string | null;\n /**\n * ENCODED channel: an already-JSON-encoded default, exactly as the column\n * stores it (`'\"Net 30\"'`, `'42'`, `'null'`), or `null` to inherit.\n *\n * This is the wire contract: the generated write routes hand the request\n * body straight to the constructor, and the #2049/#2050 gear posts\n * `JSON.stringify(draft.defaultValue)`. It must therefore keep meaning\n * \"already encoded\" — auto-serializing here would double-encode every gear\n * write.\n *\n * Use {@link FieldPolicyOptions.defaultValueRaw} for a plain value. The two\n * are mutually exclusive: `'\"TBD\"'` and `'TBD'` are indistinguishable once\n * a single option carries both meanings, so the channel must be explicit\n * rather than sniffed.\n */\n defaultValue?: string | null;\n /**\n * PLAIN channel: any value, always serialized — strings included. The\n * constructor-option twin of {@link FieldPolicy.setDefaultValue}, so\n * `{ defaultValueRaw: 'Net 30' }` stores `'\"Net 30\"'` rather than the\n * unparseable literal `Net 30`.\n */\n defaultValueRaw?: unknown;\n visibility?: FieldPolicyVisibility | null;\n help?: string | null;\n label?: string | null;\n displayOrder?: number | null;\n locked?: boolean | null;\n updatedBy?: string | null;\n}\n\n/**\n * The sparse contribution of one layer (code seed or one stored row).\n *\n * `default` is boxed so an explicit JSON `null` default (meaning \"default to\n * null\") stays distinguishable from \"this layer contributes no default\".\n */\nexport interface FieldPolicyDelta {\n default?: { value: unknown };\n visibility?: FieldPolicyVisibility;\n help?: string;\n label?: string;\n order?: number;\n locked?: boolean;\n}\n\n/** One layer's contribution to a field's resolved policy (explain variant). */\nexport interface FieldPolicyLayerContribution {\n layer: 'code' | 'app' | 'tenant' | 'user';\n /** Chain node id for `tenant` layers (root → leaf order). */\n tenantId?: string;\n /** User id for the `user` layer. */\n userId?: string;\n delta: FieldPolicyDelta;\n}\n\n/** Fully merged policy for a single field. */\nexport interface ResolvedFieldPolicy {\n fieldName: string;\n /** True when any layer resolved a default (including an explicit null). */\n hasDefault: boolean;\n /** Parsed default value; `undefined` when {@link hasDefault} is false. */\n defaultValue: unknown;\n visibility: FieldPolicyVisibility;\n help: string | null;\n label: string | null;\n order: number | null;\n /** Grouping key — code-seed only (`ui.group`), not overridable by rows. */\n group: string | null;\n locked: boolean;\n /** Mirror of the manifest required flag (nullable fields are optional). */\n required: boolean;\n /**\n * True when the resolver forced `basic` visibility because the field is\n * required and no usable default resolved (the resolver-side safety net for\n * the required-field invariant).\n */\n visibilityForced?: boolean;\n}\n\n/** Merged policy for every field of one object. */\nexport interface ResolvedObjectFieldPolicy {\n objectRef: string;\n fields: Record<string, ResolvedFieldPolicy>;\n}\n\n/**\n * Explain variant: merged result plus the ordered per-layer contributions for\n * each field, so admin/gear UIs (#2049/#2050) never re-derive precedence.\n * Layers are listed in application order (code, app, tenant chain root → leaf,\n * user) and contain ONLY contributions that survive into the merged result:\n * tenant ancestors discarded by a permission-inheritance break and user rows\n * suppressed by an effective org lock are omitted, so sequentially replaying\n * the listed deltas reproduces the merged policy.\n */\nexport interface ExplainedObjectFieldPolicy extends ResolvedObjectFieldPolicy {\n layers: Record<string, FieldPolicyLayerContribution[]>;\n}\n\n/** Minimal tenant node consumed by the hierarchy walk (mirrors smrt-features). */\nexport interface FieldPolicyTenantNode {\n id: string;\n inheritPermissions: boolean;\n cascadePermissions: boolean;\n}\n\n/** Provider returning the root → leaf tenant chain (mirrors smrt-features). */\nexport interface FieldPolicyTenantHierarchyProvider {\n getChain(tenantId: string): Promise<FieldPolicyTenantNode[]>;\n}\n\n/**\n * Loader-function DI seam (NOT a container registration): the resolver calls\n * it lazily and treats `null` as \"no hierarchy available\" (flat-tenant\n * fallback). The default loader dynamic-imports `@happyvertical/smrt-users`\n * and returns `null` when it is not installed.\n */\nexport type FieldPolicyTenantHierarchyLoader = (\n options: SmrtClassOptions,\n) => Promise<FieldPolicyTenantHierarchyProvider | null>;\n\nexport interface ResolveFieldPolicyOptions {\n tenantId?: string | null;\n userId?: string | null;\n /**\n * Database holding `_smrt_field_policies`. Without it, only the code seed\n * resolves (stored layers are skipped) — the smrt-prompts precedent.\n */\n db?: SmrtClassOptions['db'];\n tenantHierarchyLoader?: FieldPolicyTenantHierarchyLoader;\n}\n\n/**\n * Minimal structural shape of the tenant surface loaded from\n * `@happyvertical/smrt-users` by the default hierarchy loader. Only the\n * permission-cascade fields consumed by the chain walk are modeled.\n */\nexport interface FieldPolicyUsersTenantRecord {\n id: string;\n inheritPermissions?: boolean;\n cascadePermissions?: boolean;\n}\n\nexport interface FieldPolicyUsersModule {\n TenantCollection: {\n create(options: SmrtClassOptions): Promise<{\n get(criteria: {\n id: string;\n }): Promise<FieldPolicyUsersTenantRecord | null>;\n getAncestorsFromRoot(\n tenantId: string,\n ): Promise<FieldPolicyUsersTenantRecord[]>;\n }>;\n };\n}\n\n/** Result shape of the batch resolve action (`FieldPolicyCollection.resolveBatch`). */\nexport interface FieldPolicyBatchResult {\n policies: Record<string, ResolvedObjectFieldPolicy>;\n}\n\nexport type { SmrtClassOptions };\n"],"mappings":";AA+BO,IAAM,6BAA6B;AAEnC,IAAM,2BAA4D;CACvE;CACA;CACA;AACF;AAEO,IAAM,4BAA8D;CACzE;CACA;CACA;AACF"}
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "@happyvertical/smrt-fields",
3
+ "version": "0.40.57",
4
+ "description": "Layered field policy store and resolver (defaults, visibility tiers, help text) for SMRT objects",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "files": [
9
+ "dist",
10
+ "CLAUDE.md",
11
+ "AGENTS.md"
12
+ ],
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.ts",
16
+ "import": "./dist/index.js"
17
+ },
18
+ "./manifest": "./dist/manifest.json",
19
+ "./manifest.json": "./dist/manifest.json"
20
+ },
21
+ "dependencies": {
22
+ "@happyvertical/sql": "^0.84.0",
23
+ "@happyvertical/smrt-core": "0.40.57",
24
+ "@happyvertical/smrt-tenancy": "0.40.57"
25
+ },
26
+ "devDependencies": {
27
+ "@types/node": "24.13.2",
28
+ "typescript": "5.9.3",
29
+ "vite": "8.1.4",
30
+ "vitest": "4.1.10",
31
+ "@happyvertical/smrt-cli": "0.40.57",
32
+ "@happyvertical/smrt-users": "0.40.57",
33
+ "@happyvertical/smrt-vitest": "0.40.57"
34
+ },
35
+ "keywords": [
36
+ "smrt",
37
+ "field-policy",
38
+ "form-defaults",
39
+ "visibility",
40
+ "multi-tenant"
41
+ ],
42
+ "author": "HappyVertical",
43
+ "license": "MIT",
44
+ "publishConfig": {
45
+ "registry": "https://registry.npmjs.org",
46
+ "access": "public"
47
+ },
48
+ "repository": {
49
+ "type": "git",
50
+ "url": "https://github.com/happyvertical/smrt.git",
51
+ "directory": "packages/fields"
52
+ },
53
+ "scripts": {
54
+ "build": "vite build --mode library",
55
+ "build:watch": "vite build --mode library --watch",
56
+ "clean": "rm -rf dist",
57
+ "dev": "vite dev",
58
+ "test": "vitest run",
59
+ "test:watch": "vitest",
60
+ "typecheck": "tsc --noEmit -p tsconfig.json",
61
+ "verify:pack": "node ../../scripts/verify-package-types-exports.js ."
62
+ }
63
+ }