@svadmin/core 0.58.0 → 0.60.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/README.md +1 -1
- package/package.json +2 -2
- package/src/access-control-contract.ts +1 -1
- package/src/admin-tool.ts +11 -5
- package/src/approval-contract.ts +1 -1
- package/src/audit-contract.ts +3 -3
- package/src/audit.ts +1 -1
- package/src/auth-mutation-contract.ts +1 -1
- package/src/auth-query-contract.ts +1 -1
- package/src/command-contract.ts +2 -3
- package/src/contract-form.svelte.ts +1 -1
- package/src/create-contract.ts +1 -1
- package/src/create-many-contract.ts +1 -1
- package/src/dashboard-contract.ts +1 -1
- package/src/delete-contract.ts +1 -1
- package/src/delete-many-contract.ts +1 -1
- package/src/export-format.ts +1 -1
- package/src/feature-gate-contract.ts +1 -1
- package/src/helpers-pure.ts +13 -9
- package/src/http.ts +2 -2
- package/src/import-contract.ts +1 -1
- package/src/index.ts +1 -1
- package/src/inferencer.ts +83 -2
- package/src/infinite-query.svelte.ts +1 -1
- package/src/invalidation-contract.ts +1 -1
- package/src/live-sse.ts +1 -1
- package/src/live-subscription.svelte.ts +1 -1
- package/src/live-transport.ts +1 -1
- package/src/permission-catalog.ts +1 -1
- package/src/permission-hints-contract.ts +1 -1
- package/src/query-invalidation.ts +1 -1
- package/src/query-keys.ts +1 -1
- package/src/query-session.svelte.ts +1 -1
- package/src/query-snapshot.ts +17 -15
- package/src/record-decoder.ts +13 -2
- package/src/resource-contract.ts +31 -24
- package/src/resource-schemas.ts +21 -20
- package/src/resource-transport-config.ts +16 -7
- package/src/schema-form.ts +2 -2
- package/src/schema-validation.ts +119 -36
- package/src/spreadsheet-workbook.ts +1 -1
- package/src/strict-command-hooks.svelte.ts +1 -1
- package/src/task-contract.ts +1 -1
- package/src/task-provider.ts +1 -1
- package/src/update-contract.ts +1 -1
- package/src/update-many-contract.ts +1 -1
- package/src/useStepsForm.svelte.ts +1 -1
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
Use the existing `useForm.validate` callback with a shared TypeBox schema:
|
|
4
4
|
|
|
5
5
|
```ts
|
|
6
|
-
import { Type, type Static } from '
|
|
6
|
+
import { Type, type Static } from 'typebox';
|
|
7
7
|
import { createSchemaFormValidator, useForm } from '@svadmin/core';
|
|
8
8
|
|
|
9
9
|
const Input = Type.Object({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@svadmin/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.60.0",
|
|
4
4
|
"author": "zuohuadong",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -135,7 +135,7 @@
|
|
|
135
135
|
"dependencies": {
|
|
136
136
|
"@types/papaparse": "^5.5.2",
|
|
137
137
|
"@internationalized/date": "^3.12.3",
|
|
138
|
-
"
|
|
138
|
+
"typebox": "^1.3.34",
|
|
139
139
|
"papaparse": "^5.5.4"
|
|
140
140
|
},
|
|
141
141
|
"peerDependenciesMeta": {
|
package/src/admin-tool.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { StaticDecode, TObject } from '
|
|
2
|
-
import { Value } from '
|
|
1
|
+
import type { StaticDecode, TObject } from 'typebox';
|
|
2
|
+
import { Value } from 'typebox/value';
|
|
3
3
|
|
|
4
4
|
/** Result returned by a tool execution. */
|
|
5
5
|
export interface ToolResult {
|
|
@@ -61,7 +61,10 @@ export function decodeAdminToolArgs<const Schema extends TObject>(
|
|
|
61
61
|
tool: AdminTool<Schema>,
|
|
62
62
|
input: unknown,
|
|
63
63
|
): StaticDecode<Schema> {
|
|
64
|
-
|
|
64
|
+
const schema = strictAdminToolSchema(tool.parameters);
|
|
65
|
+
// TypeBox 1.x Value.Decode strips undeclared properties instead of rejecting them.
|
|
66
|
+
if (!Value.Check(schema, input)) throw new Error('Admin tool arguments do not match the declared schema');
|
|
67
|
+
return Value.Decode(schema, input);
|
|
65
68
|
}
|
|
66
69
|
|
|
67
70
|
/** Decodes and executes a tool without allowing callers to bypass runtime input validation. */
|
|
@@ -94,6 +97,9 @@ export function projectAdminToolSchema<Schema extends TObject>(tool: AdminTool<S
|
|
|
94
97
|
}
|
|
95
98
|
|
|
96
99
|
function strictAdminToolSchema<Schema extends TObject>(schema: Schema): Schema {
|
|
97
|
-
if (schema.additionalProperties === false) return schema;
|
|
98
|
-
|
|
100
|
+
if ((schema as { additionalProperties?: unknown }).additionalProperties === false) return schema;
|
|
101
|
+
// Spreading would drop TypeBox 1.x non-enumerable markers such as ~codec; clone instead.
|
|
102
|
+
const copy = Value.Clone(schema) as TObject & { additionalProperties?: boolean };
|
|
103
|
+
copy.additionalProperties = false;
|
|
104
|
+
return copy as Schema;
|
|
99
105
|
}
|
package/src/approval-contract.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Type, type Static } from '
|
|
1
|
+
import { Type, type Static } from 'typebox';
|
|
2
2
|
import { checkExact } from './schema-validation';
|
|
3
3
|
import { snapshotPlainData } from './plain-data';
|
|
4
4
|
import type { EnterpriseProviderRequestContext } from './enterprise';
|
package/src/audit-contract.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Type, type Static, type TSchema } from '
|
|
1
|
+
import { Type, ObjectOptions, type Static, type TSchema } from 'typebox';
|
|
2
2
|
import { checkExact } from './schema-validation';
|
|
3
3
|
import { snapshotPlainData } from './plain-data';
|
|
4
4
|
|
|
@@ -27,8 +27,8 @@ const entrySchema = Type.Object({
|
|
|
27
27
|
userAgent: Type.Optional(text),
|
|
28
28
|
error: Type.Optional(Type.Object({ message: Type.String(), code: Type.Optional(text) }, { additionalProperties: false })),
|
|
29
29
|
}, { additionalProperties: false });
|
|
30
|
-
const draftSchema = Type.Omit(entrySchema, ['id', 'timestamp']);
|
|
31
|
-
const createSchema = Type.Omit(entrySchema, ['id']);
|
|
30
|
+
const draftSchema = Type.Omit(entrySchema, ['id', 'timestamp'], ObjectOptions(entrySchema));
|
|
31
|
+
const createSchema = Type.Omit(entrySchema, ['id'], ObjectOptions(entrySchema));
|
|
32
32
|
const querySchema = Type.Object({
|
|
33
33
|
resource: Type.Optional(text), action: Type.Optional(text),
|
|
34
34
|
meta: Type.Optional(record), author: Type.Optional(record),
|
package/src/audit.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { definedOptions } from './defined-options';
|
|
2
|
-
import { Value } from '
|
|
2
|
+
import { Value } from 'typebox/value';
|
|
3
3
|
import {
|
|
4
4
|
AuditError, createAuditEntry, decodeAuditCreate, decodeAuditEntries, decodeAuditEntry, decodeAuditQuery,
|
|
5
5
|
type AuditCreateParams, type AuditDraft, type AuditEntry, type AuditQueryParams,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Type, type Static } from '
|
|
1
|
+
import { Type, type Static } from 'typebox';
|
|
2
2
|
import type { AuthProvider } from './types';
|
|
3
3
|
import { authErrorSchema, authRedirectSchema, decodeIdentity } from './auth-query-contract';
|
|
4
4
|
import { snapshotPlainData } from './plain-data';
|
package/src/command-contract.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import { Type, type TObject, type TSchema } from '
|
|
2
|
-
import { TypeGuard } from '@sinclair/typebox/type';
|
|
1
|
+
import { Type, type TObject, type TSchema } from 'typebox';
|
|
3
2
|
import { checkExact } from './schema-validation';
|
|
4
3
|
import { closeContractSchema, type SafeSchema, type SchemaValue } from './resource-contract';
|
|
5
4
|
import { snapshotPlainData } from './plain-data';
|
|
@@ -101,7 +100,7 @@ export function defineCommand(name: string, definition: {
|
|
|
101
100
|
const outputSchema: unknown = fields['output']?.value;
|
|
102
101
|
if (typeof url !== 'string' || !url.trim() ||
|
|
103
102
|
(method !== 'get' && method !== 'post' && method !== 'put' && method !== 'patch' && method !== 'delete') ||
|
|
104
|
-
!
|
|
103
|
+
!Type.IsObject(inputSchema) || !Type.IsSchema(outputSchema)) {
|
|
105
104
|
return invalidContract();
|
|
106
105
|
}
|
|
107
106
|
const input = closeContractSchema(inputSchema);
|
|
@@ -16,7 +16,7 @@ import { invalidateOwnedQueries } from './query-invalidation';
|
|
|
16
16
|
import { appendListQueryFromPath } from './url-sync';
|
|
17
17
|
import { HttpError } from './types';
|
|
18
18
|
import { useTranslation } from './i18n.svelte';
|
|
19
|
-
import { Type } from '
|
|
19
|
+
import { Type } from 'typebox';
|
|
20
20
|
import { checkExact } from './schema-validation';
|
|
21
21
|
import { auditWithProvider } from './audit';
|
|
22
22
|
import { fireSuccessNotification, fireErrorNotification } from './hook-utils.svelte';
|
package/src/create-contract.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Type } from '
|
|
1
|
+
import { Type } from 'typebox';
|
|
2
2
|
import { snapshotPlainData } from './plain-data';
|
|
3
3
|
import { checkExact } from './schema-validation';
|
|
4
4
|
import { parseContractId, parseContractRecord, parseContractCreateInput, requireContractCreateSchema,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Type, type Static } from '
|
|
1
|
+
import { Type, type Static } from 'typebox';
|
|
2
2
|
import { checkExact } from './schema-validation';
|
|
3
3
|
import { snapshotPlainData } from './plain-data';
|
|
4
4
|
import type { EnterpriseProviderRequestContext } from './enterprise';
|
package/src/delete-contract.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Type } from '
|
|
1
|
+
import { Type } from 'typebox';
|
|
2
2
|
import { snapshotPlainData } from './plain-data';
|
|
3
3
|
import { checkExact } from './schema-validation';
|
|
4
4
|
import { parseContractId, parseContractRecord, parseContractDeleteInput, type ContractSchemas, type ContractId, type ResourceContract } from './resource-contract';
|
package/src/export-format.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Pure export formatting helpers with no Svelte rune or browser API dependencies except downloadData.
|
|
2
2
|
// These helpers can be tested directly with bun:test.
|
|
3
3
|
|
|
4
|
-
import { Type, type Static } from '
|
|
4
|
+
import { Type, type Static } from 'typebox';
|
|
5
5
|
import { checkExact } from './schema-validation';
|
|
6
6
|
import { snapshotPlainData } from './plain-data';
|
|
7
7
|
|
package/src/helpers-pure.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Extracted to enable unit testing with bun test
|
|
3
3
|
|
|
4
4
|
import type { Filter, Sort, CrudOperator } from './types';
|
|
5
|
+
import { schemaIssues } from './schema-validation';
|
|
5
6
|
|
|
6
7
|
// ─── Table Helpers ────────────────────────────────────────────
|
|
7
8
|
|
|
@@ -168,21 +169,24 @@ export function parseCSV(text: string): string[][] {
|
|
|
168
169
|
// ─── TypeBox Validator Helper ───────────────────────────────────
|
|
169
170
|
|
|
170
171
|
/**
|
|
171
|
-
* TypeBox validator interface matching
|
|
172
|
+
* TypeBox validator interface matching Compile or Value.Errors
|
|
172
173
|
*/
|
|
173
174
|
export type TypeBoxValidatorLike = {
|
|
174
175
|
Check?: (value: unknown) => boolean;
|
|
175
|
-
Errors: (value: unknown) => Iterable<{
|
|
176
|
+
Errors: (value: unknown) => Iterable<{
|
|
177
|
+
path?: string; instancePath?: string; keyword?: string;
|
|
178
|
+
params?: Record<string, unknown>; message: string;
|
|
179
|
+
}>;
|
|
176
180
|
};
|
|
177
181
|
|
|
178
182
|
/**
|
|
179
183
|
* Creates a synchronous form validator function specifically optimized for TypeBox
|
|
180
|
-
* compiled validators (from
|
|
184
|
+
* compiled validators (from Compile) or TypeBox value checks.
|
|
181
185
|
*
|
|
182
186
|
* @example
|
|
183
187
|
* ```ts
|
|
184
|
-
* import { Type } from '
|
|
185
|
-
* import {
|
|
188
|
+
* import { Type } from 'typebox';
|
|
189
|
+
* import { Compile } from 'typebox/compile';
|
|
186
190
|
* import { createTypeBoxValidator, useForm } from '@svadmin/core';
|
|
187
191
|
*
|
|
188
192
|
* const userSchema = Type.Object({
|
|
@@ -190,7 +194,7 @@ export type TypeBoxValidatorLike = {
|
|
|
190
194
|
* email: Type.String({ format: 'email' }),
|
|
191
195
|
* });
|
|
192
196
|
*
|
|
193
|
-
* const compiledUser =
|
|
197
|
+
* const compiledUser = Compile(userSchema);
|
|
194
198
|
*
|
|
195
199
|
* const form = useForm({
|
|
196
200
|
* resource: 'users',
|
|
@@ -205,10 +209,10 @@ export function createTypeBoxValidator(
|
|
|
205
209
|
if (validator.Check?.(values)) return null;
|
|
206
210
|
|
|
207
211
|
const errors: Record<string, string> = {};
|
|
208
|
-
for (const
|
|
209
|
-
const path =
|
|
212
|
+
for (const issue of schemaIssues(validator.Errors(values))) {
|
|
213
|
+
const path = issue.path.replace(/^\//, '').replace(/\//g, '.');
|
|
210
214
|
const key = path || '_root';
|
|
211
|
-
if (!errors[key]) errors[key] =
|
|
215
|
+
if (!errors[key]) errors[key] = issue.message;
|
|
212
216
|
}
|
|
213
217
|
|
|
214
218
|
return Object.keys(errors).length > 0 ? errors : null;
|
package/src/http.ts
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { HttpError,type ValidationErrors } from './types';
|
|
6
|
-
import { Type } from '
|
|
7
|
-
import { Value } from '
|
|
6
|
+
import { Type } from 'typebox';
|
|
7
|
+
import { Value } from 'typebox/value';
|
|
8
8
|
import { definedOptions } from './defined-options';
|
|
9
9
|
|
|
10
10
|
export interface FetchWithInterceptorOptions {
|
package/src/import-contract.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import Papa from 'papaparse';
|
|
2
|
-
import { Type, type Static } from '
|
|
2
|
+
import { Type, type Static } from 'typebox';
|
|
3
3
|
import { checkExact } from './schema-validation';
|
|
4
4
|
import { snapshotPlainData } from './plain-data';
|
|
5
5
|
import { HttpError } from './types';
|
package/src/index.ts
CHANGED
|
@@ -304,7 +304,7 @@ export {
|
|
|
304
304
|
generateComponentCode,
|
|
305
305
|
generateResourceBundle,
|
|
306
306
|
} from './inferencer';
|
|
307
|
-
export type { InferResult } from './inferencer';
|
|
307
|
+
export type { InferResult, InferReview, InferReviewWarning, InferReviewWarningCode } from './inferencer';
|
|
308
308
|
export { createWebSocketLiveProvider } from './live-websocket';
|
|
309
309
|
export type { WebSocketLiveProviderOptions } from './live-websocket';
|
|
310
310
|
export { createSSELiveProvider } from './live-sse';
|
package/src/inferencer.ts
CHANGED
|
@@ -84,6 +84,7 @@ export interface InferResult {
|
|
|
84
84
|
resource: ResourceDefinition;
|
|
85
85
|
code: string;
|
|
86
86
|
typeboxCode: string;
|
|
87
|
+
review: InferReview;
|
|
87
88
|
componentCode: {
|
|
88
89
|
list: string;
|
|
89
90
|
create: string;
|
|
@@ -92,6 +93,26 @@ export interface InferResult {
|
|
|
92
93
|
};
|
|
93
94
|
}
|
|
94
95
|
|
|
96
|
+
export type InferReviewWarningCode =
|
|
97
|
+
| 'empty-sample'
|
|
98
|
+
| 'nullable-field'
|
|
99
|
+
| 'mixed-types'
|
|
100
|
+
| 'heuristic-relation'
|
|
101
|
+
| 'heuristic-select';
|
|
102
|
+
|
|
103
|
+
export interface InferReviewWarning {
|
|
104
|
+
code: InferReviewWarningCode;
|
|
105
|
+
field?: string;
|
|
106
|
+
message: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface InferReview {
|
|
110
|
+
needsReview: boolean;
|
|
111
|
+
confidence: 'high' | 'medium' | 'low';
|
|
112
|
+
sampleSize: number;
|
|
113
|
+
warnings: InferReviewWarning[];
|
|
114
|
+
}
|
|
115
|
+
|
|
95
116
|
/**
|
|
96
117
|
* Analyze a sample data array and produce a ResourceDefinition.
|
|
97
118
|
* @param resourceName - Name of the resource (e.g. "posts")
|
|
@@ -116,6 +137,15 @@ export function inferResource(
|
|
|
116
137
|
resource: emptyResource,
|
|
117
138
|
code: `// No data available to infer fields for "${resourceName}".`,
|
|
118
139
|
typeboxCode: generateTypeBoxSchemaCode(emptyResource),
|
|
140
|
+
review: {
|
|
141
|
+
needsReview: true,
|
|
142
|
+
confidence: 'low',
|
|
143
|
+
sampleSize: 0,
|
|
144
|
+
warnings: [{
|
|
145
|
+
code: 'empty-sample',
|
|
146
|
+
message: `No sample records were available for "${resourceName}".`,
|
|
147
|
+
}],
|
|
148
|
+
},
|
|
119
149
|
componentCode: {
|
|
120
150
|
list: generateListPageCode(emptyResource),
|
|
121
151
|
create: generateCreatePageCode(emptyResource),
|
|
@@ -133,6 +163,7 @@ export function inferResource(
|
|
|
133
163
|
|
|
134
164
|
// For each key, infer from all non-null values
|
|
135
165
|
const fields: FieldDefinition[] = [];
|
|
166
|
+
const warnings: InferReviewWarning[] = [];
|
|
136
167
|
for (const key of keySet) {
|
|
137
168
|
// Collect non-null values
|
|
138
169
|
const values = sampleData
|
|
@@ -141,6 +172,13 @@ export function inferResource(
|
|
|
141
172
|
|
|
142
173
|
const sampleValue = values[0];
|
|
143
174
|
let inferredType = inferFieldType(key, sampleValue);
|
|
175
|
+
if (values.length === 0) {
|
|
176
|
+
warnings.push({
|
|
177
|
+
code: 'nullable-field',
|
|
178
|
+
field: key,
|
|
179
|
+
message: `Field "${key}" contains only null or undefined values in the sample.`,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
144
182
|
|
|
145
183
|
// Cross-validate: if most values for this key are of a different type, use majority
|
|
146
184
|
const typeCounts = new Map<InferredType, number>();
|
|
@@ -155,13 +193,34 @@ export function inferResource(
|
|
|
155
193
|
inferredType = t;
|
|
156
194
|
}
|
|
157
195
|
}
|
|
196
|
+
if (typeCounts.size > 1) {
|
|
197
|
+
warnings.push({
|
|
198
|
+
code: 'mixed-types',
|
|
199
|
+
field: key,
|
|
200
|
+
message: `Field "${key}" contains multiple inferred types; the majority type was selected.`,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
158
203
|
|
|
159
204
|
// Check for relation
|
|
160
205
|
const relatedResource = isLikelyRelation(key);
|
|
206
|
+
if (relatedResource) {
|
|
207
|
+
warnings.push({
|
|
208
|
+
code: 'heuristic-relation',
|
|
209
|
+
field: key,
|
|
210
|
+
message: `Relation "${key}" was inferred from its field name.`,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
161
213
|
|
|
162
214
|
// Check if it looks like a select (few unique string values)
|
|
163
215
|
const uniqueStrings = new Set(values.filter(v => typeof v === 'string') as string[]);
|
|
164
216
|
const isSelect = inferredType === 'text' && uniqueStrings.size > 1 && uniqueStrings.size <= 10 && values.length >= 5;
|
|
217
|
+
if (isSelect) {
|
|
218
|
+
warnings.push({
|
|
219
|
+
code: 'heuristic-select',
|
|
220
|
+
field: key,
|
|
221
|
+
message: `Select options for "${key}" were inferred from repeated sample values.`,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
165
224
|
|
|
166
225
|
const field: FieldDefinition = {
|
|
167
226
|
key,
|
|
@@ -213,7 +272,23 @@ export function inferResource(
|
|
|
213
272
|
show: generateShowPageCode(resource),
|
|
214
273
|
};
|
|
215
274
|
|
|
216
|
-
return {
|
|
275
|
+
return {
|
|
276
|
+
fields,
|
|
277
|
+
resource,
|
|
278
|
+
code,
|
|
279
|
+
typeboxCode,
|
|
280
|
+
review: {
|
|
281
|
+
needsReview: warnings.length > 0,
|
|
282
|
+
confidence: warnings.some((warning) => warning.code === 'mixed-types' || warning.code === 'nullable-field')
|
|
283
|
+
? 'low'
|
|
284
|
+
: warnings.length > 0
|
|
285
|
+
? 'medium'
|
|
286
|
+
: 'high',
|
|
287
|
+
sampleSize: sampleData.length,
|
|
288
|
+
warnings,
|
|
289
|
+
},
|
|
290
|
+
componentCode,
|
|
291
|
+
};
|
|
217
292
|
}
|
|
218
293
|
|
|
219
294
|
// ─── Code Generation ─────────────────────────────────────────
|
|
@@ -407,7 +482,7 @@ export function generateTypeBoxSchemaCode(resource: ResourceDefinition): string
|
|
|
407
482
|
const baseName = resource.name.endsWith('s') ? resource.name.slice(0, -1) : resource.name;
|
|
408
483
|
const typeName = capitalize(baseName);
|
|
409
484
|
|
|
410
|
-
return `import { Type, type Static } from '
|
|
485
|
+
return `import { Type, type Static } from 'typebox';
|
|
411
486
|
|
|
412
487
|
export const ${typeName}Schema = Type.Object({
|
|
413
488
|
${schemaProps}
|
|
@@ -462,6 +537,12 @@ export function generateResourceBundle(resource: ResourceDefinition): InferResul
|
|
|
462
537
|
resource,
|
|
463
538
|
code: generateResourceCode(resource),
|
|
464
539
|
typeboxCode: generateTypeBoxSchemaCode(resource),
|
|
540
|
+
review: {
|
|
541
|
+
needsReview: false,
|
|
542
|
+
confidence: 'high',
|
|
543
|
+
sampleSize: 0,
|
|
544
|
+
warnings: [],
|
|
545
|
+
},
|
|
465
546
|
componentCode: {
|
|
466
547
|
list: generateListPageCode(resource),
|
|
467
548
|
create: generateCreatePageCode(resource),
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createInfiniteQuery, type InfiniteData, type InfiniteQueryObserverBaseResult, type QueryObserverResult,
|
|
2
2
|
type RefetchOptions, type FetchNextPageOptions, type FetchPreviousPageOptions } from '@tanstack/svelte-query';
|
|
3
3
|
import { tick } from 'svelte';
|
|
4
|
-
import { Type } from '
|
|
4
|
+
import { Type } from 'typebox';
|
|
5
5
|
import { definedOptions } from './defined-options';
|
|
6
6
|
import { getAdminOptions } from './options.svelte';
|
|
7
7
|
import { useParsed } from './useParsed.svelte';
|
package/src/live-sse.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { LiveProvider } from './live.svelte';
|
|
2
2
|
import { createLiveSubscribers, decodeLiveMessage, notifyLiveObserver, readLiveOptions, captureLiveObserver } from './live-transport';
|
|
3
|
-
import { Type } from '
|
|
3
|
+
import { Type } from 'typebox';
|
|
4
4
|
import { checkExact } from './schema-validation';
|
|
5
5
|
import { snapshotPlainData } from './plain-data';
|
|
6
6
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { untrack } from 'svelte';
|
|
2
2
|
import type { QueryClient } from '@tanstack/svelte-query';
|
|
3
|
-
import { Type } from '
|
|
3
|
+
import { Type } from 'typebox';
|
|
4
4
|
import { captureAdminContext } from './context.svelte';
|
|
5
5
|
import { contractKey, type ResourceContract } from './resource-contract';
|
|
6
6
|
import { captureQueryProvider } from './query-snapshot';
|
package/src/live-transport.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Type, type Static } from '
|
|
1
|
+
import { Type, type Static } from 'typebox';
|
|
2
2
|
import { checkExact } from './schema-validation';
|
|
3
3
|
import { snapshotPlainData, type JsonValue } from './plain-data';
|
|
4
4
|
import { definedOptions } from './defined-options';
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Type, type Static } from '
|
|
1
|
+
import { Type, type Static } from 'typebox';
|
|
2
2
|
import { checkExact } from './schema-validation';
|
|
3
3
|
import { snapshotPlainData } from './plain-data';
|
|
4
4
|
import type { AccessControlOptions, AccessControlProvider, CanParams } from './permissions.svelte';
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Type } from '
|
|
1
|
+
import { Type } from 'typebox';
|
|
2
2
|
import { checkExact } from './schema-validation';
|
|
3
3
|
import { snapshotPlainData } from './plain-data';
|
|
4
4
|
import { decodePermissionSnapshot, permissionSnapshotSchema, type PermissionSnapshot } from './permission-catalog';
|
|
@@ -4,7 +4,7 @@ import { snapshotPlainData } from './plain-data';
|
|
|
4
4
|
import { snapshotInvalidationParams } from './invalidation-contract';
|
|
5
5
|
import { definedOptions } from './defined-options';
|
|
6
6
|
import { HttpError } from './types';
|
|
7
|
-
import { Type } from '
|
|
7
|
+
import { Type } from 'typebox';
|
|
8
8
|
import { checkExact } from './schema-validation';
|
|
9
9
|
|
|
10
10
|
export interface QueryCacheOwner {
|
package/src/query-keys.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Type,type Static } from '
|
|
1
|
+
import { Type,type Static } from 'typebox';
|
|
2
2
|
import { checkExact } from './schema-validation';
|
|
3
3
|
import { definedOptions } from './defined-options';
|
|
4
4
|
import { snapshotPlainData, type JsonValue } from './plain-data';
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { hashKey, useQueryClient, type QueryObserverPendingResult, type QueryObserverResult, type RefetchOptions } from '@tanstack/svelte-query';
|
|
2
2
|
import { untrack } from 'svelte';
|
|
3
|
-
import { Type } from '
|
|
3
|
+
import { Type } from 'typebox';
|
|
4
4
|
import type { AdminContextAccessor } from './context.svelte';
|
|
5
5
|
import { captureAuthLiveScope, clearAuthQueries, handleAuthError } from './auth-hooks.svelte';
|
|
6
6
|
import { decodeBaseRecord } from './record-decoder';
|
package/src/query-snapshot.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Type, type Static, type TSchema } from '
|
|
1
|
+
import { Type, type Static, type TSchema } from 'typebox';
|
|
2
2
|
import type { AdminContextAccessor } from './context.svelte';
|
|
3
3
|
import type { DataProvider, GetListParams, GetOneParams, GetManyParams } from './types';
|
|
4
4
|
import { HttpError } from './types';
|
|
@@ -8,20 +8,22 @@ import { attachAbortSignal, detachAbortSignal, snapshotPlainData } from './plain
|
|
|
8
8
|
import { checkExact } from './schema-validation';
|
|
9
9
|
import { decodeBaseRecord, decodeListResult, decodeOneResult, decodeManyResult, rejectProviderResponse } from './record-decoder';
|
|
10
10
|
|
|
11
|
-
const filters = Type.
|
|
12
|
-
Type.
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
11
|
+
const filters = Type.Cyclic({
|
|
12
|
+
Filters: Type.Union([
|
|
13
|
+
Type.Object({
|
|
14
|
+
field: Type.String(),
|
|
15
|
+
operator: Type.Union(([
|
|
16
|
+
'eq', 'ne', 'lt', 'gt', 'lte', 'gte', 'in', 'nin', 'contains', 'ncontains',
|
|
17
|
+
'startswith', 'endswith', 'null', 'nnull', 'between', 'nbetween',
|
|
18
|
+
] as const).map(value => Type.Literal(value))),
|
|
19
|
+
value: Type.Unknown(),
|
|
20
|
+
}, { additionalProperties: false }),
|
|
21
|
+
Type.Object({
|
|
22
|
+
operator: Type.Union([Type.Literal('and'), Type.Literal('or')]),
|
|
23
|
+
value: Type.Array(Type.Ref('Filters')),
|
|
24
|
+
}, { additionalProperties: false }),
|
|
25
|
+
]),
|
|
26
|
+
}, 'Filters');
|
|
25
27
|
const base = {
|
|
26
28
|
resource: Type.String({ minLength: 1 }),
|
|
27
29
|
meta: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
package/src/record-decoder.ts
CHANGED
|
@@ -1,11 +1,22 @@
|
|
|
1
|
-
import { Type } from '
|
|
1
|
+
import { Type } from 'typebox';
|
|
2
2
|
import { createExactSchemaValidator } from './schema-validation';
|
|
3
3
|
import { snapshotPlainData } from './plain-data';
|
|
4
4
|
import { HttpError, type BaseRecord, type GetListResult, type GetOneResult, type GetManyResult, type CustomResult } from './types';
|
|
5
5
|
|
|
6
6
|
export type RecordDecoder<T extends BaseRecord> = (value: unknown) => T;
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
/** TypeBox 1.x Record validation accepts class instances; keep records limited to plain JSON objects. */
|
|
9
|
+
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
|
10
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
|
|
11
|
+
const prototype: unknown = Object.getPrototypeOf(value);
|
|
12
|
+
return prototype === Object.prototype || prototype === null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const record = Type.Refine(
|
|
16
|
+
Type.Record(Type.String(), Type.Unknown()),
|
|
17
|
+
isPlainRecord,
|
|
18
|
+
() => 'Expected plain object',
|
|
19
|
+
);
|
|
9
20
|
const recordValidator = createExactSchemaValidator(record);
|
|
10
21
|
const customValidator = createExactSchemaValidator(Type.Object({ data: Type.Unknown() }));
|
|
11
22
|
const oneValidator = createExactSchemaValidator(Type.Object({ data: record }));
|
package/src/resource-contract.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { Type, type Static, type TObject, type TSchema } from 'typebox';
|
|
2
|
+
import { Value } from 'typebox/value';
|
|
3
|
+
import { checkExact, createExactSchemaValidator, schemaIssues } from './schema-validation';
|
|
4
4
|
import { DeleteManyPartialError, HttpError, type DataProvider, type KnownResources, type BaseRecord,
|
|
5
5
|
type GetListResult, type GetManyParams, type CreateManyParams, type UpdateManyParams, type DeleteManyParams,
|
|
6
6
|
type GetOneParams, type GetOneResult, type GetManyResult } from './types';
|
|
@@ -70,26 +70,33 @@ interface Definition {
|
|
|
70
70
|
const definitions = new WeakMap<ResourceContract, Definition>();
|
|
71
71
|
let nextKey = 0;
|
|
72
72
|
|
|
73
|
+
/** TypeBox 1.x stores the schema kind under the non-enumerable `~kind` marker instead of a symbol. */
|
|
74
|
+
function schemaKind(schema: TSchema): string | undefined {
|
|
75
|
+
const kind = (schema as { '~kind'?: unknown })['~kind'];
|
|
76
|
+
return typeof kind === 'string' ? kind : undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
73
79
|
// A deliberately closed JSON subset keeps runtime validation aligned with Static.
|
|
74
80
|
export function closeContractSchema<S extends TSchema>(schema: S): S {
|
|
75
|
-
const copy =
|
|
81
|
+
const copy = Value.Clone(schema);
|
|
76
82
|
const visit = (node: TSchema): void => {
|
|
77
|
-
if (
|
|
83
|
+
if ('~codec' in node) {
|
|
78
84
|
throw new HttpError('Contract transforms require a separate decoding boundary', 400, undefined, { code: 'INVALID_RESOURCE_CONTRACT' });
|
|
79
85
|
}
|
|
80
|
-
if (!
|
|
86
|
+
if (!Type.IsSchema(node) || !['Object', 'Array', 'Tuple', 'Union', 'String', 'Number', 'Integer', 'Boolean', 'Null', 'Literal', 'Never'].includes(schemaKind(node) ?? '')) {
|
|
81
87
|
throw new HttpError('Unsupported or unconstrained contract schema', 400, undefined, { code: 'INVALID_RESOURCE_CONTRACT' });
|
|
82
88
|
}
|
|
83
|
-
if (
|
|
84
|
-
|
|
89
|
+
if (Type.IsObject(node)) {
|
|
90
|
+
const objectNode = node as TSchema & { additionalProperties?: TSchema | boolean };
|
|
91
|
+
if (objectNode.additionalProperties !== undefined && objectNode.additionalProperties !== false) {
|
|
85
92
|
throw new HttpError('Contract objects must be closed', 400, undefined, { code: 'INVALID_RESOURCE_CONTRACT' });
|
|
86
93
|
}
|
|
87
|
-
|
|
94
|
+
objectNode.additionalProperties = false;
|
|
88
95
|
for (const property of Object.values(node.properties)) visit(property);
|
|
89
96
|
}
|
|
90
|
-
if (
|
|
91
|
-
if (
|
|
92
|
-
if (
|
|
97
|
+
if (Type.IsArray(node)) visit(node.items);
|
|
98
|
+
if (Type.IsTuple(node)) for (const item of node.items ?? []) visit(item);
|
|
99
|
+
if (Type.IsUnion(node)) for (const item of node.anyOf) visit(item);
|
|
93
100
|
};
|
|
94
101
|
visit(copy);
|
|
95
102
|
return copy;
|
|
@@ -101,11 +108,11 @@ export function defineResource<const S extends ContractSchemas>(
|
|
|
101
108
|
): ResourceContract<S>;
|
|
102
109
|
export function defineResource(name: KnownResources, schemas: ContractSchemas): ResourceContract {
|
|
103
110
|
const isId = (schema: TSchema): boolean => (
|
|
104
|
-
['String', 'Number', 'Integer'].includes(schema
|
|
105
|
-
|| (
|
|
106
|
-
|| (
|
|
111
|
+
['String', 'Number', 'Integer'].includes(schemaKind(schema) ?? '')
|
|
112
|
+
|| (Type.IsLiteral(schema) && ['string', 'number'].includes(typeof schema.const))
|
|
113
|
+
|| (Type.IsUnion(schema) && schema.anyOf.every(isId))
|
|
107
114
|
);
|
|
108
|
-
if (!name.trim() || schemas.record
|
|
115
|
+
if (!name.trim() || schemaKind(schemas.record) !== 'Object'
|
|
109
116
|
|| !schemas.record.required?.includes('id')
|
|
110
117
|
|| !schemas.record.properties['id']
|
|
111
118
|
|| !isId(schemas.record.properties['id'])) {
|
|
@@ -177,7 +184,7 @@ export interface ContractProjectionOptions {
|
|
|
177
184
|
}
|
|
178
185
|
|
|
179
186
|
function projectBySchema(schema: TSchema, value: unknown): unknown {
|
|
180
|
-
if (
|
|
187
|
+
if (Type.IsObject(schema)) {
|
|
181
188
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) return value;
|
|
182
189
|
const source = value as Record<string, unknown>;
|
|
183
190
|
const projected: Record<string, unknown> = {};
|
|
@@ -186,15 +193,15 @@ function projectBySchema(schema: TSchema, value: unknown): unknown {
|
|
|
186
193
|
}
|
|
187
194
|
return projected;
|
|
188
195
|
}
|
|
189
|
-
if (
|
|
196
|
+
if (Type.IsArray(schema)) {
|
|
190
197
|
return Array.isArray(value) ? value.map(item => projectBySchema(schema.items, item)) : value;
|
|
191
198
|
}
|
|
192
|
-
if (
|
|
199
|
+
if (Type.IsTuple(schema)) {
|
|
193
200
|
return Array.isArray(value)
|
|
194
201
|
? value.map((item, index) => schema.items?.[index] ? projectBySchema(schema.items[index], item) : item)
|
|
195
202
|
: value;
|
|
196
203
|
}
|
|
197
|
-
if (
|
|
204
|
+
if (Type.IsUnion(schema)) {
|
|
198
205
|
const branch = schema.anyOf.find(candidate => checkExact(candidate, value));
|
|
199
206
|
return branch ? projectBySchema(branch, value) : value;
|
|
200
207
|
}
|
|
@@ -394,7 +401,7 @@ export function parseContractDeleteInput(contract: ResourceContract, value: unkn
|
|
|
394
401
|
export function contractFormValues(contract: ResourceContract, action: string, value: Record<string, unknown>): Record<string, unknown> {
|
|
395
402
|
const schemas = definitionOf(contract).schemas;
|
|
396
403
|
const schema = action === 'edit' ? schemas.update : schemas.create;
|
|
397
|
-
if (!schema || !
|
|
404
|
+
if (!schema || !Type.IsObject(schema)) {
|
|
398
405
|
throw new HttpError('Forms require an object input schema for their action', 400, undefined, { code: 'INVALID_RESOURCE_CONTRACT' });
|
|
399
406
|
}
|
|
400
407
|
const projected = Object.fromEntries(Object.keys(schema.properties)
|
|
@@ -406,7 +413,7 @@ export function contractFormValues(contract: ResourceContract, action: string, v
|
|
|
406
413
|
function formSchema(contract: ResourceContract, action: ContractFormAction): TObject {
|
|
407
414
|
const schemas = definitionOf(contract).schemas;
|
|
408
415
|
const schema = action === 'show' ? schemas.record : action === 'edit' ? schemas.update : schemas.create;
|
|
409
|
-
if (!schema || !
|
|
416
|
+
if (!schema || !Type.IsObject(schema)) {
|
|
410
417
|
throw new HttpError('Forms require an object schema for their action', 400, undefined, { code: 'INVALID_RESOURCE_CONTRACT' });
|
|
411
418
|
}
|
|
412
419
|
return schema;
|
|
@@ -445,7 +452,7 @@ export function snapshotContractFormDraft(contract: ResourceContract, action: Co
|
|
|
445
452
|
export function contractFormInvalidFields(contract: ResourceContract, action: ContractFormAction, value: unknown): string[] {
|
|
446
453
|
const schema = formSchema(contract, action);
|
|
447
454
|
const validator = createExactSchemaValidator(schema);
|
|
448
|
-
const paths =
|
|
455
|
+
const paths = schemaIssues(validator.Errors(value)).map(issue => issue.path);
|
|
449
456
|
const fields = Object.keys(schema.properties).filter(field => {
|
|
450
457
|
const path = `/${field.replaceAll('~', '~0').replaceAll('/', '~1')}`;
|
|
451
458
|
return paths.some(candidate => candidate === path || candidate.startsWith(`${path}/`));
|
|
@@ -460,7 +467,7 @@ export function parseContractFormInput<S extends ContractSchemas, A extends 'cre
|
|
|
460
467
|
export function parseContractFormInput(contract: ResourceContract, action: 'create' | 'edit', value: unknown): BaseRecord {
|
|
461
468
|
const schemas = definitionOf(contract).schemas;
|
|
462
469
|
const schema = action === 'edit' ? schemas.update : schemas.create;
|
|
463
|
-
if (!schema || !
|
|
470
|
+
if (!schema || !Type.IsObject(schema)) {
|
|
464
471
|
throw new HttpError('Forms require an object input schema for their action', 400, undefined, { code: 'INVALID_RESOURCE_CONTRACT' });
|
|
465
472
|
}
|
|
466
473
|
check(schema, value, 'values');
|
package/src/resource-schemas.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { Type, type Static, type TSchema } from '
|
|
2
|
-
import { checkExact, createExactSchemaValidator } from './schema-validation';
|
|
1
|
+
import { Type, type Static, type TSchema } from 'typebox';
|
|
2
|
+
import { checkExact, createExactSchemaValidator, schemaIssues } from './schema-validation';
|
|
3
3
|
import { attachAbortSignal, detachAbortSignal, snapshotPlainData } from './plain-data';
|
|
4
4
|
import { decodeBaseRecord, decodeOneResult, decodeManyResult, decodeListResult } from './record-decoder';
|
|
5
5
|
import { HttpError } from './types';
|
|
@@ -43,22 +43,24 @@ const common = {
|
|
|
43
43
|
resource: Type.String({ minLength: 1 }),
|
|
44
44
|
meta: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
|
45
45
|
};
|
|
46
|
-
const filter = Type.
|
|
47
|
-
Type.
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
46
|
+
const filter = Type.Cyclic({
|
|
47
|
+
Filter: Type.Union([
|
|
48
|
+
Type.Object({
|
|
49
|
+
operator: Type.Union([Type.Literal('or'), Type.Literal('and')]),
|
|
50
|
+
value: Type.Array(Type.Ref('Filter')),
|
|
51
|
+
}, { additionalProperties: false }),
|
|
52
|
+
Type.Object({
|
|
53
|
+
field: Type.String(),
|
|
54
|
+
operator: Type.Union([
|
|
55
|
+
Type.Literal('eq'), Type.Literal('ne'), Type.Literal('lt'), Type.Literal('gt'),
|
|
56
|
+
Type.Literal('lte'), Type.Literal('gte'), Type.Literal('contains'), Type.Literal('ncontains'),
|
|
57
|
+
Type.Literal('startswith'), Type.Literal('endswith'), Type.Literal('in'), Type.Literal('nin'),
|
|
58
|
+
Type.Literal('null'), Type.Literal('nnull'), Type.Literal('between'), Type.Literal('nbetween'),
|
|
59
|
+
]),
|
|
60
|
+
value: Type.Unknown(),
|
|
61
|
+
}, { additionalProperties: false }),
|
|
62
|
+
]),
|
|
63
|
+
}, 'Filter');
|
|
62
64
|
const requestSchemas = {
|
|
63
65
|
getList: Type.Object({
|
|
64
66
|
...common,
|
|
@@ -166,8 +168,7 @@ function createSchemaBoundary(schemas: ResourceSchemaMap) {
|
|
|
166
168
|
}
|
|
167
169
|
if (validator.Check(value)) return;
|
|
168
170
|
// Do not include rejected values or raw responses in diagnostic details.
|
|
169
|
-
issues =
|
|
170
|
-
.map(({ path, message }) => ({ path, message }));
|
|
171
|
+
issues = schemaIssues(validator.Errors(value));
|
|
171
172
|
} catch {
|
|
172
173
|
throw new HttpError('Unable to evaluate resource schema', 500, undefined, {
|
|
173
174
|
code: 'RESOURCE_SCHEMA_INVALID',
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { TypeGuard } from '@sinclair/typebox/type';
|
|
1
|
+
import { Type, type TSchema } from 'typebox';
|
|
3
2
|
import { HttpError } from './types';
|
|
4
3
|
import type { DataProvider } from './types';
|
|
5
4
|
import type { ResourceSchemas } from './resource-schemas';
|
|
@@ -15,6 +14,9 @@ function invalidProvider(): never {
|
|
|
15
14
|
});
|
|
16
15
|
}
|
|
17
16
|
|
|
17
|
+
/** Scalar TypeBox 1.x markers that may be carried through a configuration snapshot. */
|
|
18
|
+
const TYPEBOX_MARKERS = new Set(['~kind', '~optional', '~readonly', '~immutable']);
|
|
19
|
+
|
|
18
20
|
/** Configuration can carry TypeBox markers, but no getters, functions or foreign symbols. */
|
|
19
21
|
function copySchemaData(value: unknown, ancestors = new Set<object>(), depth = 0): unknown {
|
|
20
22
|
if (depth > 128) return invalidSchema();
|
|
@@ -41,11 +43,18 @@ function copySchemaData(value: unknown, ancestors = new Set<object>(), depth = 0
|
|
|
41
43
|
}
|
|
42
44
|
const copy: Record<PropertyKey, unknown> = {};
|
|
43
45
|
for (const key of Reflect.ownKeys(descriptors)) {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
+
// TypeBox 1.x marks schema metadata with non-enumerable `~`-prefixed string keys.
|
|
47
|
+
if (typeof key === 'symbol') return invalidSchema();
|
|
48
|
+
if (key.startsWith('~')) {
|
|
49
|
+
if (!TYPEBOX_MARKERS.has(key)) return invalidSchema();
|
|
50
|
+
const marker: PropertyDescriptor | undefined = Reflect.get(descriptors, key);
|
|
51
|
+
if (!marker || !('value' in marker) || marker.get || marker.set) return invalidSchema();
|
|
52
|
+
const markerValue: unknown = marker.value;
|
|
53
|
+
if (typeof markerValue !== 'string' && typeof markerValue !== 'boolean') return invalidSchema();
|
|
54
|
+
Object.defineProperty(copy, key, { value: markerValue, enumerable: false, configurable: true, writable: false });
|
|
55
|
+
continue;
|
|
46
56
|
}
|
|
47
57
|
const field = read(key);
|
|
48
|
-
if (typeof key === 'symbol' && typeof field !== 'string') return invalidSchema();
|
|
49
58
|
Object.defineProperty(copy, key, { value: field, enumerable: true, configurable: true, writable: true });
|
|
50
59
|
}
|
|
51
60
|
return copy;
|
|
@@ -56,7 +65,7 @@ function copySchemaData(value: unknown, ancestors = new Set<object>(), depth = 0
|
|
|
56
65
|
|
|
57
66
|
function schema(value: unknown): TSchema {
|
|
58
67
|
const copy = copySchemaData(value);
|
|
59
|
-
if (!
|
|
68
|
+
if (!Type.IsSchema(copy) || typeof (copy as { '~kind'?: unknown })['~kind'] !== 'string') return invalidSchema();
|
|
60
69
|
return copy;
|
|
61
70
|
}
|
|
62
71
|
|
|
@@ -88,7 +97,7 @@ export function captureResourceSchemas(value: unknown): Map<string, ResourceSche
|
|
|
88
97
|
if (!Array.isArray(copied)) return invalidSchema();
|
|
89
98
|
const candidates: unknown[] = copied;
|
|
90
99
|
for (const candidate of candidates) {
|
|
91
|
-
if (!
|
|
100
|
+
if (!Type.IsSchema(candidate)) return invalidSchema();
|
|
92
101
|
references.push(candidate);
|
|
93
102
|
}
|
|
94
103
|
}
|
package/src/schema-form.ts
CHANGED
package/src/schema-validation.ts
CHANGED
|
@@ -1,17 +1,39 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
|
|
1
|
+
import { Type, type Static, type TSchema } from 'typebox';
|
|
2
|
+
import { Value } from 'typebox/value';
|
|
3
|
+
|
|
4
|
+
const CODEC_KEY = '~codec';
|
|
5
|
+
|
|
6
|
+
/** JSON Schema keywords that TypeBox 1.x stores but does not expose on every typed interface. */
|
|
7
|
+
interface SchemaMeta {
|
|
8
|
+
$id?: string;
|
|
9
|
+
$defs?: Record<string, TSchema>;
|
|
10
|
+
properties?: Record<string, TSchema>;
|
|
11
|
+
required?: readonly string[];
|
|
12
|
+
additionalProperties?: TSchema | boolean;
|
|
13
|
+
patternProperties?: Record<string, TSchema>;
|
|
14
|
+
contains?: TSchema;
|
|
15
|
+
unevaluatedProperties?: TSchema | boolean;
|
|
16
|
+
anyOf?: TSchema[];
|
|
17
|
+
allOf?: TSchema[];
|
|
18
|
+
items?: TSchema | TSchema[];
|
|
19
|
+
not?: TSchema;
|
|
20
|
+
}
|
|
4
21
|
|
|
5
22
|
/**
|
|
6
23
|
* Strengthen optional-property presence with ordinary schemas, without changing
|
|
7
24
|
* TypeBox's process-wide policy or maintaining a second value validator.
|
|
8
25
|
*/
|
|
9
|
-
function exactSchema(
|
|
10
|
-
|
|
26
|
+
function exactSchema(input: TSchema): TSchema {
|
|
27
|
+
const schema = input as TSchema & SchemaMeta;
|
|
28
|
+
if (typeof schema.not === 'object' && Type.IsSchema(schema.not)) {
|
|
29
|
+
schema.not = exactSchema(schema.not);
|
|
30
|
+
}
|
|
31
|
+
if (Type.IsObject(input)) {
|
|
32
|
+
const properties = schema.properties ?? {};
|
|
11
33
|
schema.properties = Object.fromEntries(
|
|
12
|
-
Object.entries(
|
|
34
|
+
Object.entries(properties).map(([key, property]) => [key, exactSchema(property)]),
|
|
13
35
|
);
|
|
14
|
-
if (
|
|
36
|
+
if (typeof schema.additionalProperties === 'object' && Type.IsSchema(schema.additionalProperties)) {
|
|
15
37
|
schema.additionalProperties = exactSchema(schema.additionalProperties);
|
|
16
38
|
}
|
|
17
39
|
const optional = Object.entries(schema.properties)
|
|
@@ -20,7 +42,8 @@ function exactSchema(schema: TSchema): TSchema {
|
|
|
20
42
|
|
|
21
43
|
const constraints = optional.map(([key, property]) => Type.Union([
|
|
22
44
|
Type.Required(Type.Object({ [key]: property })),
|
|
23
|
-
Type.Not
|
|
45
|
+
// TypeBox 1.x removed Type.Not; the JSON Schema `not` keyword is expressed through Unsafe.
|
|
46
|
+
Type.Unsafe({ not: Type.Object({ [key]: Type.Unknown() }) }),
|
|
24
47
|
]));
|
|
25
48
|
// The presence constraint owns each optional value's validation. Leaving it
|
|
26
49
|
// in both places would validate deep optional objects exponentially often.
|
|
@@ -28,57 +51,117 @@ function exactSchema(schema: TSchema): TSchema {
|
|
|
28
51
|
// References must resolve to the strengthened object, not its inner copy.
|
|
29
52
|
const id = schema.$id;
|
|
30
53
|
delete schema.$id;
|
|
31
|
-
const
|
|
32
|
-
Reflect.deleteProperty(schema,
|
|
54
|
+
const codec = Object.getOwnPropertyDescriptor(schema, CODEC_KEY);
|
|
55
|
+
Reflect.deleteProperty(schema, CODEC_KEY);
|
|
33
56
|
const result = Type.Intersect([schema, ...constraints], id === undefined ? {} : { $id: id });
|
|
34
|
-
if (
|
|
57
|
+
if (codec !== undefined) Object.defineProperty(result, CODEC_KEY, codec);
|
|
35
58
|
return result;
|
|
36
59
|
}
|
|
37
|
-
if (
|
|
38
|
-
schema.items = exactSchema(schema.items);
|
|
39
|
-
if (
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
} else if (
|
|
43
|
-
schema.
|
|
44
|
-
} else if (
|
|
45
|
-
schema.
|
|
46
|
-
|
|
60
|
+
if (Type.IsArray(input)) {
|
|
61
|
+
schema.items = exactSchema(schema.items as TSchema);
|
|
62
|
+
if (typeof schema.contains === 'object' && Type.IsSchema(schema.contains)) {
|
|
63
|
+
schema.contains = exactSchema(schema.contains);
|
|
64
|
+
}
|
|
65
|
+
} else if (Type.IsTuple(input)) {
|
|
66
|
+
if (Array.isArray(schema.items)) schema.items = schema.items.map(exactSchema);
|
|
67
|
+
} else if (Type.IsUnion(input)) {
|
|
68
|
+
schema.anyOf = (schema.anyOf ?? []).map(exactSchema);
|
|
69
|
+
} else if (Type.IsIntersect(input)) {
|
|
70
|
+
schema.allOf = (schema.allOf ?? []).map(exactSchema);
|
|
71
|
+
if (typeof schema.unevaluatedProperties === 'object' && Type.IsSchema(schema.unevaluatedProperties)) {
|
|
47
72
|
schema.unevaluatedProperties = exactSchema(schema.unevaluatedProperties);
|
|
48
73
|
}
|
|
49
|
-
} else if (
|
|
50
|
-
schema.not = exactSchema(schema.not);
|
|
51
|
-
} else if (TypeGuard.IsRecord(schema)) {
|
|
74
|
+
} else if (Type.IsRecord(input)) {
|
|
52
75
|
schema.patternProperties = Object.fromEntries(
|
|
53
|
-
Object.entries(schema.patternProperties).map(([key, property]) => [key, exactSchema(property)]),
|
|
76
|
+
Object.entries(schema.patternProperties ?? {}).map(([key, property]) => [key, exactSchema(property)]),
|
|
54
77
|
);
|
|
55
|
-
if (
|
|
78
|
+
if (typeof schema.additionalProperties === 'object' && Type.IsSchema(schema.additionalProperties)) {
|
|
56
79
|
schema.additionalProperties = exactSchema(schema.additionalProperties);
|
|
57
80
|
}
|
|
58
|
-
} else if (
|
|
81
|
+
} else if (Type.IsCyclic(input)) {
|
|
59
82
|
schema.$defs = Object.fromEntries(
|
|
60
|
-
Object.entries(schema.$defs).map(([key, definition]) =>
|
|
61
|
-
if (!TypeGuard.IsSchema(definition)) throw new TypeError('Invalid imported schema definition');
|
|
62
|
-
return [key, exactSchema(definition)];
|
|
63
|
-
}),
|
|
83
|
+
Object.entries(schema.$defs ?? {}).map(([key, definition]) => [key, exactSchema(definition)]),
|
|
64
84
|
);
|
|
65
85
|
}
|
|
66
86
|
return schema;
|
|
67
87
|
}
|
|
68
88
|
|
|
89
|
+
/** TypeBox 1.x resolves references through a Context keyed by Ref name instead of a `references` array. */
|
|
90
|
+
function referenceContext(references: readonly TSchema[]): Record<string, TSchema> {
|
|
91
|
+
const context: Record<string, TSchema> = {};
|
|
92
|
+
for (const reference of references) {
|
|
93
|
+
const id = (reference as SchemaMeta).$id;
|
|
94
|
+
if (typeof id === 'string' && id.length > 0) context[id] = reference;
|
|
95
|
+
}
|
|
96
|
+
return context;
|
|
97
|
+
}
|
|
98
|
+
|
|
69
99
|
export function createExactSchemaValidator<S extends TSchema>(schema: S, references: readonly TSchema[] = []) {
|
|
70
|
-
const checked = exactSchema(
|
|
71
|
-
const checkedReferences = references.map(reference => exactSchema(
|
|
100
|
+
const checked = exactSchema(Value.Clone(schema));
|
|
101
|
+
const checkedReferences = references.map(reference => exactSchema(Value.Clone(reference)));
|
|
102
|
+
const context = referenceContext(checkedReferences);
|
|
103
|
+
const hasReferences = Object.keys(context).length > 0;
|
|
72
104
|
return {
|
|
73
105
|
Check(value: unknown): value is Static<S> {
|
|
74
|
-
return
|
|
106
|
+
return hasReferences
|
|
107
|
+
? Value.Check(context, checked, value)
|
|
108
|
+
: Value.Check(checked, value);
|
|
75
109
|
},
|
|
76
110
|
Errors(value: unknown) {
|
|
77
|
-
return
|
|
111
|
+
return hasReferences
|
|
112
|
+
? Value.Errors(context, checked, value)
|
|
113
|
+
: Value.Errors(checked, value);
|
|
78
114
|
},
|
|
79
115
|
};
|
|
80
116
|
}
|
|
81
117
|
|
|
118
|
+
/** One normalized validation issue with a field-scoped JSON Pointer. */
|
|
119
|
+
export interface SchemaIssue {
|
|
120
|
+
path: string;
|
|
121
|
+
message: string;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
interface SchemaErrorLike {
|
|
125
|
+
instancePath?: string;
|
|
126
|
+
path?: string;
|
|
127
|
+
keyword?: string;
|
|
128
|
+
params?: Record<string, unknown>;
|
|
129
|
+
message: string;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function escapePointerSegment(segment: string): string {
|
|
133
|
+
return segment.replaceAll('~', '~0').replaceAll('/', '~1');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* TypeBox 1.x reports Ajv-style errors that group missing properties into a single
|
|
138
|
+
* `required` issue. Normalize every error back into one field-scoped issue so callers
|
|
139
|
+
* can keep mapping messages onto individual form fields.
|
|
140
|
+
*/
|
|
141
|
+
export function schemaIssues(errors: Iterable<SchemaErrorLike>): SchemaIssue[] {
|
|
142
|
+
const issues: SchemaIssue[] = [];
|
|
143
|
+
for (const error of errors) {
|
|
144
|
+
const base = error.instancePath ?? error.path ?? '';
|
|
145
|
+
const required = error.params?.['requiredProperties'];
|
|
146
|
+
if (error.keyword === 'required' && Array.isArray(required)) {
|
|
147
|
+
for (const property of required) {
|
|
148
|
+
if (typeof property !== 'string') continue;
|
|
149
|
+
issues.push({
|
|
150
|
+
path: `${base}/${escapePointerSegment(property)}`,
|
|
151
|
+
message: `must have required property '${property}'`,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const additional = error.params?.['additionalProperty'];
|
|
157
|
+
issues.push({
|
|
158
|
+
path: typeof additional === 'string' ? `${base}/${escapePointerSegment(additional)}` : base,
|
|
159
|
+
message: error.message,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
return issues;
|
|
163
|
+
}
|
|
164
|
+
|
|
82
165
|
const validators = new WeakMap<TSchema, ReturnType<typeof createExactSchemaValidator>>();
|
|
83
166
|
|
|
84
167
|
/** Schemas are snapshotted on first use; later edits cannot weaken a live boundary. */
|
|
@@ -89,4 +172,4 @@ export function checkExact<S extends TSchema>(schema: S, value: unknown): value
|
|
|
89
172
|
validators.set(schema, validator);
|
|
90
173
|
}
|
|
91
174
|
return validator.Check(value);
|
|
92
|
-
}
|
|
175
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { TObject, TSchema } from '
|
|
1
|
+
import type { TObject, TSchema } from 'typebox';
|
|
2
2
|
import { hashKey, useQueryClient } from '@tanstack/svelte-query';
|
|
3
3
|
import { captureAdminContext, type AdminContextAccessor } from './context.svelte';
|
|
4
4
|
import { commandDefinition, prepareCommand, parseCommandInput, parseCommandResponse,
|
package/src/task-contract.ts
CHANGED
package/src/task-provider.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Type } from '
|
|
1
|
+
import { Type } from 'typebox';
|
|
2
2
|
import { checkExact } from './schema-validation';
|
|
3
3
|
import { snapshotPlainData } from './plain-data';
|
|
4
4
|
import { TaskError, decodeTaskList, decodeTaskRecord, decodeTaskSubmitOptions, type SubmitTaskOptions } from './task-contract';
|
package/src/update-contract.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Type } from '
|
|
1
|
+
import { Type } from 'typebox';
|
|
2
2
|
import { useContractForm, type ContractFormOptions } from './contract-form.svelte';
|
|
3
3
|
import { getContractFormFields, type ContractSchemas, type ContractFormValues } from './resource-contract';
|
|
4
4
|
import { snapshotPlainData } from './plain-data';
|