@vibeorm/runtime 1.1.3 → 1.1.5
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 +49 -1
- package/package.json +1 -1
- package/src/client.ts +16 -0
- package/src/index.ts +4 -0
- package/src/retry.ts +183 -0
- package/src/view.ts +629 -0
package/README.md
CHANGED
|
@@ -59,6 +59,54 @@ Per-operation `countStrategy` takes precedence over the client default.
|
|
|
59
59
|
|
|
60
60
|
Bridges a `DatabaseAdapter` to a `SqlExecutor` for use with the migrate package.
|
|
61
61
|
|
|
62
|
+
### `withRetry(fn, options?)`
|
|
63
|
+
|
|
64
|
+
Retry utility with exponential backoff and jitter. Only retries `VibeTransientError` instances (connection failures, deadlocks, serialization conflicts, statement timeouts, pool exhaustion). Deterministic errors (`VibeRequestError`) are never retried.
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
import { withRetry } from "@vibeorm/runtime";
|
|
68
|
+
|
|
69
|
+
// Basic — 3 retries, exponential backoff with jitter
|
|
70
|
+
const users = await withRetry(() =>
|
|
71
|
+
db.user.findMany({ where: { active: true } })
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
// Custom options
|
|
75
|
+
const user = await withRetry(
|
|
76
|
+
() => db.user.create({ data: { email: "new@example.com" } }),
|
|
77
|
+
{ maxRetries: 5, baseDelay: 100, maxDelay: 10000 },
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
// Retry only specific error codes
|
|
81
|
+
const result = await withRetry(
|
|
82
|
+
() => db.$transaction(async (tx) => {
|
|
83
|
+
/* ... */
|
|
84
|
+
}, { isolationLevel: "Serializable" }),
|
|
85
|
+
{ retryOn: ["SERIALIZATION_FAILURE", "DEADLOCK"] },
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
// With abort signal
|
|
89
|
+
const controller = new AbortController();
|
|
90
|
+
const result = await withRetry(
|
|
91
|
+
() => db.user.findFirst({ where: { id: 1 } }),
|
|
92
|
+
{ signal: controller.signal },
|
|
93
|
+
);
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
**Options (`RetryOptions`):**
|
|
97
|
+
|
|
98
|
+
| Option | Default | Description |
|
|
99
|
+
|--------|---------|-------------|
|
|
100
|
+
| `maxRetries` | `3` | Max retry attempts (total executions = maxRetries + 1) |
|
|
101
|
+
| `baseDelay` | `50` | Base delay in ms for exponential backoff |
|
|
102
|
+
| `maxDelay` | `5000` | Maximum delay cap in ms |
|
|
103
|
+
| `maxJitter` | `50` | Random jitter added per retry to prevent thundering herd |
|
|
104
|
+
| `signal` | — | `AbortSignal` to cancel pending retries |
|
|
105
|
+
| `retryOn` | — | Filter to retry only specific `VibeTransientErrorCode`s |
|
|
106
|
+
| `onRetry` | — | Callback `({ error, attempt, delay }) => void` for logging/metrics |
|
|
107
|
+
|
|
108
|
+
Backoff formula: `min(baseDelay * 2^attempt + random(0, maxJitter), maxDelay)`
|
|
109
|
+
|
|
62
110
|
### `VibeValidationError`
|
|
63
111
|
|
|
64
112
|
Custom error class thrown when Zod validation fails on input or output data.
|
|
@@ -86,7 +134,7 @@ type DatabaseAdapter = {
|
|
|
86
134
|
|
|
87
135
|
### Exported Types
|
|
88
136
|
|
|
89
|
-
`DatabaseAdapter`, `QueryResult`, `SqlExecutor`, `VibeClientOptions`, `ModelMeta`, `ModelMetaMap`, `ModelSchemas`, `ValidationSchema`, `QueryProfile`, `RelationProfile`, `ScalarFieldMeta`, `RelationFieldMeta`, `SqlQuery`, `Operation`.
|
|
137
|
+
`DatabaseAdapter`, `QueryResult`, `SqlExecutor`, `VibeClientOptions`, `ModelMeta`, `ModelMetaMap`, `ModelSchemas`, `ValidationSchema`, `QueryProfile`, `RelationProfile`, `ScalarFieldMeta`, `RelationFieldMeta`, `SqlQuery`, `Operation`, `RetryOptions`.
|
|
90
138
|
|
|
91
139
|
## License
|
|
92
140
|
|
package/package.json
CHANGED
package/src/client.ts
CHANGED
|
@@ -36,6 +36,8 @@ import {
|
|
|
36
36
|
executeLateralJoinQuery,
|
|
37
37
|
} from "./lateral-join-builder.ts";
|
|
38
38
|
import { generateDefault } from "./id-generators.ts";
|
|
39
|
+
import { createView } from "./view.ts";
|
|
40
|
+
import type { ViewDefinition } from "./view.ts";
|
|
39
41
|
|
|
40
42
|
/**
|
|
41
43
|
* Creates a VibeORM client instance.
|
|
@@ -1218,6 +1220,20 @@ export function createClient(params: {
|
|
|
1218
1220
|
await adapter.disconnect();
|
|
1219
1221
|
};
|
|
1220
1222
|
|
|
1223
|
+
// defineView — create a read-only scoped client restricted to specific fields per model.
|
|
1224
|
+
client.defineView = function (viewParams: {
|
|
1225
|
+
name: string;
|
|
1226
|
+
definition: ViewDefinition;
|
|
1227
|
+
}): Record<string, unknown> {
|
|
1228
|
+
return createView({
|
|
1229
|
+
client,
|
|
1230
|
+
name: viewParams.name,
|
|
1231
|
+
definition: viewParams.definition,
|
|
1232
|
+
allModelsMeta,
|
|
1233
|
+
schemas: params.schemas,
|
|
1234
|
+
});
|
|
1235
|
+
};
|
|
1236
|
+
|
|
1221
1237
|
// Eager connection: warm up the pool immediately on client creation.
|
|
1222
1238
|
// This runs in the background — it won't block client construction,
|
|
1223
1239
|
// but will ensure connections are ready before the first real query.
|
package/src/index.ts
CHANGED
|
@@ -20,6 +20,10 @@ export {
|
|
|
20
20
|
executeLateralJoinQuery,
|
|
21
21
|
} from "./lateral-join-builder.ts";
|
|
22
22
|
export { toSqlExecutor } from "./adapter.ts";
|
|
23
|
+
export { withRetry } from "./retry.ts";
|
|
24
|
+
export { ViewResult, createView } from "./view.ts";
|
|
25
|
+
export type { ViewDefinition } from "./view.ts";
|
|
26
|
+
export type { RetryOptions } from "./retry.ts";
|
|
23
27
|
|
|
24
28
|
export type {
|
|
25
29
|
DatabaseAdapter,
|
package/src/retry.ts
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retry utility with exponential backoff and jitter.
|
|
3
|
+
*
|
|
4
|
+
* Only retries on transient errors (VibeTransientError) — connection failures,
|
|
5
|
+
* deadlocks, serialization failures, statement timeouts, and pool exhaustion.
|
|
6
|
+
* Deterministic errors (VibeRequestError) are never retried.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```ts
|
|
10
|
+
* import { withRetry } from "@vibeorm/runtime";
|
|
11
|
+
*
|
|
12
|
+
* // Basic — 3 retries, exponential backoff with jitter
|
|
13
|
+
* const users = await withRetry(() =>
|
|
14
|
+
* db.user.findMany({ where: { active: true } })
|
|
15
|
+
* );
|
|
16
|
+
*
|
|
17
|
+
* // Custom options
|
|
18
|
+
* const user = await withRetry(
|
|
19
|
+
* () => db.user.create({ data: { email: "new@example.com" } }),
|
|
20
|
+
* { maxRetries: 5, baseDelay: 100, maxDelay: 10000 },
|
|
21
|
+
* );
|
|
22
|
+
*
|
|
23
|
+
* // With abort signal
|
|
24
|
+
* const controller = new AbortController();
|
|
25
|
+
* const result = await withRetry(
|
|
26
|
+
* () => db.user.findFirst({ where: { id: 1 } }),
|
|
27
|
+
* { signal: controller.signal },
|
|
28
|
+
* );
|
|
29
|
+
*
|
|
30
|
+
* // Retry only specific error codes
|
|
31
|
+
* const result = await withRetry(
|
|
32
|
+
* () => db.$transaction(async (tx) => { ... }, { isolationLevel: "Serializable" }),
|
|
33
|
+
* { retryOn: ["SERIALIZATION_FAILURE", "DEADLOCK"] },
|
|
34
|
+
* );
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { VibeTransientError } from "./errors.ts";
|
|
39
|
+
import type { VibeTransientErrorCode } from "./errors.ts";
|
|
40
|
+
|
|
41
|
+
// ─── Types ───────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Options for configuring retry behavior.
|
|
45
|
+
*/
|
|
46
|
+
export type RetryOptions = {
|
|
47
|
+
/**
|
|
48
|
+
* Maximum number of retry attempts (default: 3).
|
|
49
|
+
* The first execution is not counted as a retry, so the operation
|
|
50
|
+
* may execute up to `maxRetries + 1` times total.
|
|
51
|
+
*/
|
|
52
|
+
maxRetries?: number;
|
|
53
|
+
/**
|
|
54
|
+
* Base delay in milliseconds for exponential backoff (default: 50).
|
|
55
|
+
* Actual delay = min(baseDelay * 2^attempt + jitter, maxDelay).
|
|
56
|
+
*/
|
|
57
|
+
baseDelay?: number;
|
|
58
|
+
/**
|
|
59
|
+
* Maximum delay in milliseconds between retries (default: 5000).
|
|
60
|
+
* Caps the exponential growth to prevent excessively long waits.
|
|
61
|
+
*/
|
|
62
|
+
maxDelay?: number;
|
|
63
|
+
/**
|
|
64
|
+
* Maximum jitter in milliseconds added to each delay (default: 50).
|
|
65
|
+
* Randomized per retry to prevent thundering herd effects when
|
|
66
|
+
* many clients retry simultaneously after a shared failure.
|
|
67
|
+
*/
|
|
68
|
+
maxJitter?: number;
|
|
69
|
+
/**
|
|
70
|
+
* Optional AbortSignal to cancel pending retries.
|
|
71
|
+
* When aborted, the last error is thrown immediately without
|
|
72
|
+
* further retry attempts.
|
|
73
|
+
*/
|
|
74
|
+
signal?: AbortSignal;
|
|
75
|
+
/**
|
|
76
|
+
* Optional filter to retry only specific transient error codes.
|
|
77
|
+
* When provided, only errors with matching codes are retried;
|
|
78
|
+
* other transient errors are thrown immediately.
|
|
79
|
+
*
|
|
80
|
+
* When not provided, all transient errors are retried.
|
|
81
|
+
*/
|
|
82
|
+
retryOn?: VibeTransientErrorCode[];
|
|
83
|
+
/**
|
|
84
|
+
* Optional callback invoked before each retry attempt.
|
|
85
|
+
* Useful for logging or metrics.
|
|
86
|
+
*/
|
|
87
|
+
onRetry?: (params: { error: VibeTransientError; attempt: number; delay: number }) => void;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// ─── Implementation ──────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Compute the delay for a retry attempt using exponential backoff with jitter.
|
|
94
|
+
* Formula: min(baseDelay * 2^attempt + random(0, maxJitter), maxDelay)
|
|
95
|
+
*/
|
|
96
|
+
function computeDelay(params: {
|
|
97
|
+
attempt: number;
|
|
98
|
+
baseDelay: number;
|
|
99
|
+
maxDelay: number;
|
|
100
|
+
maxJitter: number;
|
|
101
|
+
}): number {
|
|
102
|
+
const { attempt, baseDelay, maxDelay, maxJitter } = params;
|
|
103
|
+
const exponential = baseDelay * Math.pow(2, attempt);
|
|
104
|
+
const jitter = Math.random() * maxJitter;
|
|
105
|
+
return Math.min(exponential + jitter, maxDelay);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Sleep for a given number of milliseconds.
|
|
110
|
+
* Resolves immediately if the signal is already aborted.
|
|
111
|
+
*/
|
|
112
|
+
function sleep(params: { ms: number; signal?: AbortSignal }): Promise<void> {
|
|
113
|
+
const { ms, signal } = params;
|
|
114
|
+
if (signal?.aborted) return Promise.resolve();
|
|
115
|
+
return new Promise((resolve) => {
|
|
116
|
+
const timer = setTimeout(resolve, ms);
|
|
117
|
+
signal?.addEventListener("abort", () => {
|
|
118
|
+
clearTimeout(timer);
|
|
119
|
+
resolve();
|
|
120
|
+
}, { once: true });
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Execute a function with automatic retry on transient errors.
|
|
126
|
+
*
|
|
127
|
+
* Uses exponential backoff with jitter to space out retries and
|
|
128
|
+
* prevent thundering herd effects. Only retries `VibeTransientError`
|
|
129
|
+
* instances — deterministic errors (constraint violations, validation
|
|
130
|
+
* errors, etc.) are thrown immediately.
|
|
131
|
+
*
|
|
132
|
+
* @param fn - The async operation to execute and potentially retry.
|
|
133
|
+
* @param options - Optional retry configuration.
|
|
134
|
+
* @returns The result of the first successful execution.
|
|
135
|
+
* @throws The last error if all retries are exhausted.
|
|
136
|
+
*/
|
|
137
|
+
export async function withRetry<T>(
|
|
138
|
+
fn: () => Promise<T>,
|
|
139
|
+
options?: RetryOptions,
|
|
140
|
+
): Promise<T> {
|
|
141
|
+
const maxRetries = options?.maxRetries ?? 3;
|
|
142
|
+
const baseDelay = options?.baseDelay ?? 50;
|
|
143
|
+
const maxDelay = options?.maxDelay ?? 5000;
|
|
144
|
+
const maxJitter = options?.maxJitter ?? 50;
|
|
145
|
+
const signal = options?.signal;
|
|
146
|
+
const retryOn = options?.retryOn;
|
|
147
|
+
const onRetry = options?.onRetry;
|
|
148
|
+
|
|
149
|
+
let lastError: unknown;
|
|
150
|
+
|
|
151
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
152
|
+
try {
|
|
153
|
+
return await fn();
|
|
154
|
+
} catch (err) {
|
|
155
|
+
lastError = err;
|
|
156
|
+
|
|
157
|
+
// Only retry transient errors
|
|
158
|
+
if (!(err instanceof VibeTransientError)) throw err;
|
|
159
|
+
|
|
160
|
+
// If retryOn filter is set, only retry matching codes
|
|
161
|
+
if (retryOn && !retryOn.includes(err.code)) throw err;
|
|
162
|
+
|
|
163
|
+
// Don't retry if we've exhausted attempts
|
|
164
|
+
if (attempt >= maxRetries) throw err;
|
|
165
|
+
|
|
166
|
+
// Don't retry if aborted
|
|
167
|
+
if (signal?.aborted) throw err;
|
|
168
|
+
|
|
169
|
+
const delay = computeDelay({ attempt, baseDelay, maxDelay, maxJitter });
|
|
170
|
+
|
|
171
|
+
// Notify before sleeping
|
|
172
|
+
onRetry?.({ error: err, attempt: attempt + 1, delay });
|
|
173
|
+
|
|
174
|
+
await sleep({ ms: delay, signal });
|
|
175
|
+
|
|
176
|
+
// Check abort again after sleep
|
|
177
|
+
if (signal?.aborted) throw err;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Should never reach here, but TypeScript needs it
|
|
182
|
+
throw lastError;
|
|
183
|
+
}
|
package/src/view.ts
ADDED
|
@@ -0,0 +1,629 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VibeORM Views — Read-only scoped client with field-level restrictions.
|
|
3
|
+
*
|
|
4
|
+
* Views restrict which fields are returned by default, provide a `reveal`
|
|
5
|
+
* keyword for explicit opt-in to extra fields, and enforce serialization
|
|
6
|
+
* safety via toJSON() that excludes revealed fields.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type {
|
|
10
|
+
ModelMeta,
|
|
11
|
+
ModelMetaMap,
|
|
12
|
+
ModelSchemas,
|
|
13
|
+
ValidationSchema,
|
|
14
|
+
} from "./types.ts";
|
|
15
|
+
import { getModelByNameMap } from "./types.ts";
|
|
16
|
+
|
|
17
|
+
// ─── Types ────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* View definition: maps camelCase model keys to sets of allowed field names.
|
|
21
|
+
* Example: { user: { id: true, name: true }, post: { id: true, title: true } }
|
|
22
|
+
*/
|
|
23
|
+
export type ViewDefinition = Record<string, Record<string, true>>;
|
|
24
|
+
|
|
25
|
+
// ─── ViewResult Class ─────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Wraps a query result record with serialization safety.
|
|
29
|
+
*
|
|
30
|
+
* - All fields (view + revealed) are accessible as direct properties.
|
|
31
|
+
* - `toJSON()` excludes revealed fields (called by JSON.stringify).
|
|
32
|
+
* - `$unsafe()` returns a plain object with ALL fields.
|
|
33
|
+
*
|
|
34
|
+
* Internal tracking (`_revealedFields`, `$unsafe`, `toJSON`) is non-enumerable
|
|
35
|
+
* so it doesn't appear in Object.keys(), for...in, or spread.
|
|
36
|
+
*/
|
|
37
|
+
export class ViewResult {
|
|
38
|
+
/** Allow dynamic property access for view/revealed fields. */
|
|
39
|
+
[key: string]: unknown;
|
|
40
|
+
|
|
41
|
+
/** @internal — set of field names that were revealed (non-enumerable). */
|
|
42
|
+
declare readonly _revealedFields: ReadonlySet<string>;
|
|
43
|
+
/** @internal — set of field names that are relation fields (non-enumerable). */
|
|
44
|
+
declare readonly _relationFields: ReadonlySet<string>;
|
|
45
|
+
|
|
46
|
+
constructor(params: {
|
|
47
|
+
record: Record<string, unknown>;
|
|
48
|
+
viewFields: Record<string, true>;
|
|
49
|
+
revealedFields: Record<string, true>;
|
|
50
|
+
relationFields?: Set<string>;
|
|
51
|
+
}) {
|
|
52
|
+
const { record, viewFields, revealedFields, relationFields } = params;
|
|
53
|
+
|
|
54
|
+
// Track which fields are revealed
|
|
55
|
+
const revealedSet = new Set(Object.keys(revealedFields));
|
|
56
|
+
Object.defineProperty(this, "_revealedFields", {
|
|
57
|
+
value: revealedSet,
|
|
58
|
+
enumerable: false,
|
|
59
|
+
writable: false,
|
|
60
|
+
configurable: false,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// Track relation field names
|
|
64
|
+
Object.defineProperty(this, "_relationFields", {
|
|
65
|
+
value: relationFields ?? new Set<string>(),
|
|
66
|
+
enumerable: false,
|
|
67
|
+
writable: false,
|
|
68
|
+
configurable: false,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// Assign all view + revealed fields as enumerable properties
|
|
72
|
+
const allowedFields = new Set([
|
|
73
|
+
...Object.keys(viewFields),
|
|
74
|
+
...Object.keys(revealedFields),
|
|
75
|
+
]);
|
|
76
|
+
|
|
77
|
+
for (const key of allowedFields) {
|
|
78
|
+
if (key in record) {
|
|
79
|
+
(this as Record<string, unknown>)[key] = record[key];
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Also assign relation fields that may be present
|
|
84
|
+
if (relationFields) {
|
|
85
|
+
for (const relName of relationFields) {
|
|
86
|
+
if (relName in record) {
|
|
87
|
+
(this as Record<string, unknown>)[relName] = record[relName];
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Returns a plain object with only view fields (excludes revealed fields).
|
|
95
|
+
* Called automatically by JSON.stringify().
|
|
96
|
+
*/
|
|
97
|
+
toJSON(): Record<string, unknown> {
|
|
98
|
+
const obj: Record<string, unknown> = {};
|
|
99
|
+
for (const key of Object.keys(this)) {
|
|
100
|
+
if (this._revealedFields.has(key)) continue;
|
|
101
|
+
const value = (this as Record<string, unknown>)[key];
|
|
102
|
+
if (value instanceof ViewResult) {
|
|
103
|
+
obj[key] = value.toJSON();
|
|
104
|
+
} else if (Array.isArray(value)) {
|
|
105
|
+
obj[key] = value.map((item) =>
|
|
106
|
+
item instanceof ViewResult ? item.toJSON() : item
|
|
107
|
+
);
|
|
108
|
+
} else {
|
|
109
|
+
obj[key] = value;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return obj;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Returns a plain object with ALL fields (view + revealed).
|
|
117
|
+
* Use this as an escape hatch when you need the full data.
|
|
118
|
+
*/
|
|
119
|
+
$unsafe(): Record<string, unknown> {
|
|
120
|
+
const obj: Record<string, unknown> = {};
|
|
121
|
+
for (const key of Object.keys(this)) {
|
|
122
|
+
const value = (this as Record<string, unknown>)[key];
|
|
123
|
+
if (value instanceof ViewResult) {
|
|
124
|
+
obj[key] = value.$unsafe();
|
|
125
|
+
} else if (Array.isArray(value)) {
|
|
126
|
+
obj[key] = value.map((item) =>
|
|
127
|
+
item instanceof ViewResult ? item.$unsafe() : item
|
|
128
|
+
);
|
|
129
|
+
} else {
|
|
130
|
+
obj[key] = value;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return obj;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Make $unsafe non-enumerable on prototype (toJSON is already non-enumerable by default on prototype)
|
|
138
|
+
Object.defineProperty(ViewResult.prototype, "$unsafe", {
|
|
139
|
+
...Object.getOwnPropertyDescriptor(ViewResult.prototype, "$unsafe"),
|
|
140
|
+
enumerable: false,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
Object.defineProperty(ViewResult.prototype, "toJSON", {
|
|
144
|
+
...Object.getOwnPropertyDescriptor(ViewResult.prototype, "toJSON"),
|
|
145
|
+
enumerable: false,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// ─── Result Wrapping ──────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Wrap raw query result records into ViewResult instances.
|
|
152
|
+
*/
|
|
153
|
+
export function wrapResultsWithView(params: {
|
|
154
|
+
records: Record<string, unknown>[];
|
|
155
|
+
viewFields: Record<string, true>;
|
|
156
|
+
revealedFields: Record<string, true>;
|
|
157
|
+
viewDefinition: ViewDefinition;
|
|
158
|
+
modelMeta: ModelMeta;
|
|
159
|
+
allModelsMeta: ModelMetaMap;
|
|
160
|
+
}): ViewResult[] {
|
|
161
|
+
const { records, viewFields, revealedFields, viewDefinition, modelMeta, allModelsMeta } = params;
|
|
162
|
+
const modelMap = getModelByNameMap({ allModelsMeta });
|
|
163
|
+
|
|
164
|
+
// Build set of relation field names present on this model
|
|
165
|
+
const relationFieldSet = new Set<string>();
|
|
166
|
+
for (const rel of modelMeta.relationFields) {
|
|
167
|
+
relationFieldSet.add(rel.name);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return records.map((record) => {
|
|
171
|
+
// Recursively wrap relation fields
|
|
172
|
+
const wrappedRecord: Record<string, unknown> = { ...record };
|
|
173
|
+
|
|
174
|
+
for (const rel of modelMeta.relationFields) {
|
|
175
|
+
const relValue = record[rel.name];
|
|
176
|
+
if (relValue === undefined || relValue === null) continue;
|
|
177
|
+
|
|
178
|
+
// Find the related model's camelCase key in allModelsMeta
|
|
179
|
+
const relatedModelMeta = modelMap.get(rel.relatedModel);
|
|
180
|
+
if (!relatedModelMeta) continue;
|
|
181
|
+
|
|
182
|
+
// Find the camelCase key for this related model in the view definition
|
|
183
|
+
const relatedViewKey = findModelKeyInMeta({
|
|
184
|
+
modelName: rel.relatedModel,
|
|
185
|
+
allModelsMeta,
|
|
186
|
+
});
|
|
187
|
+
const relatedViewFields = relatedViewKey
|
|
188
|
+
? viewDefinition[relatedViewKey]
|
|
189
|
+
: undefined;
|
|
190
|
+
|
|
191
|
+
if (relatedViewFields) {
|
|
192
|
+
// Relation is in view definition — restrict its fields
|
|
193
|
+
if (Array.isArray(relValue)) {
|
|
194
|
+
wrappedRecord[rel.name] = wrapResultsWithView({
|
|
195
|
+
records: relValue as Record<string, unknown>[],
|
|
196
|
+
viewFields: relatedViewFields,
|
|
197
|
+
revealedFields: extractNestedReveal({ args: {}, relationName: rel.name }),
|
|
198
|
+
viewDefinition,
|
|
199
|
+
modelMeta: relatedModelMeta,
|
|
200
|
+
allModelsMeta,
|
|
201
|
+
});
|
|
202
|
+
} else if (typeof relValue === "object") {
|
|
203
|
+
const wrapped = wrapResultsWithView({
|
|
204
|
+
records: [relValue as Record<string, unknown>],
|
|
205
|
+
viewFields: relatedViewFields,
|
|
206
|
+
revealedFields: extractNestedReveal({ args: {}, relationName: rel.name }),
|
|
207
|
+
viewDefinition,
|
|
208
|
+
modelMeta: relatedModelMeta,
|
|
209
|
+
allModelsMeta,
|
|
210
|
+
});
|
|
211
|
+
wrappedRecord[rel.name] = wrapped[0] ?? null;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
// If relation is NOT in view definition, leave as-is (no restriction)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return new ViewResult({
|
|
218
|
+
record: wrappedRecord,
|
|
219
|
+
viewFields,
|
|
220
|
+
revealedFields,
|
|
221
|
+
relationFields: relationFieldSet,
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Extract the nested reveal fields for a specific relation from the args.
|
|
228
|
+
* For now returns empty — reveal handling is done at args merging time.
|
|
229
|
+
*/
|
|
230
|
+
function extractNestedReveal(_params: {
|
|
231
|
+
args: Record<string, unknown>;
|
|
232
|
+
relationName: string;
|
|
233
|
+
}): Record<string, true> {
|
|
234
|
+
return {};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Find the camelCase key for a PascalCase model name in allModelsMeta.
|
|
239
|
+
*/
|
|
240
|
+
function findModelKeyInMeta(params: {
|
|
241
|
+
modelName: string;
|
|
242
|
+
allModelsMeta: ModelMetaMap;
|
|
243
|
+
}): string | undefined {
|
|
244
|
+
for (const [key, meta] of Object.entries(params.allModelsMeta)) {
|
|
245
|
+
if (meta.name === params.modelName) return key;
|
|
246
|
+
}
|
|
247
|
+
return undefined;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ─── Args Merging ─────────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Merge view field definitions with user-provided args.
|
|
254
|
+
*
|
|
255
|
+
* This injects the view's field set as a `select`, handles `reveal`
|
|
256
|
+
* by merging extra fields into the SQL select, and recursively
|
|
257
|
+
* restricts relation includes.
|
|
258
|
+
*/
|
|
259
|
+
export function mergeViewArgs(params: {
|
|
260
|
+
args: Record<string, unknown>;
|
|
261
|
+
viewFields: Record<string, true>;
|
|
262
|
+
viewDefinition: ViewDefinition;
|
|
263
|
+
modelMeta: ModelMeta;
|
|
264
|
+
allModelsMeta: ModelMetaMap;
|
|
265
|
+
}): {
|
|
266
|
+
mergedArgs: Record<string, unknown>;
|
|
267
|
+
revealedFields: Record<string, true>;
|
|
268
|
+
} {
|
|
269
|
+
const { args, viewFields, viewDefinition, modelMeta, allModelsMeta } = params;
|
|
270
|
+
const modelMap = getModelByNameMap({ allModelsMeta });
|
|
271
|
+
|
|
272
|
+
// Extract reveal from args
|
|
273
|
+
const reveal = (args.reveal ?? {}) as Record<string, boolean>;
|
|
274
|
+
const revealedFields: Record<string, true> = {};
|
|
275
|
+
for (const [key, val] of Object.entries(reveal)) {
|
|
276
|
+
if (val === true) {
|
|
277
|
+
revealedFields[key] = true;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Build the effective select
|
|
282
|
+
let effectiveSelect: Record<string, true>;
|
|
283
|
+
|
|
284
|
+
const userSelect = args.select as Record<string, boolean> | undefined;
|
|
285
|
+
if (userSelect) {
|
|
286
|
+
// User select narrows within view fields — only allow keys in viewFields
|
|
287
|
+
effectiveSelect = {};
|
|
288
|
+
for (const [key, val] of Object.entries(userSelect)) {
|
|
289
|
+
if (val === true && key in viewFields) {
|
|
290
|
+
effectiveSelect[key] = true;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
} else {
|
|
294
|
+
// Default: use all view fields
|
|
295
|
+
effectiveSelect = { ...viewFields };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Merge revealed fields into effective select (they go to SQL but are tracked separately)
|
|
299
|
+
for (const key of Object.keys(revealedFields)) {
|
|
300
|
+
effectiveSelect[key] = true;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Process includes — restrict relation fields by view definition
|
|
304
|
+
const include = args.include as Record<string, unknown> | undefined;
|
|
305
|
+
let mergedInclude: Record<string, unknown> | undefined;
|
|
306
|
+
|
|
307
|
+
if (include) {
|
|
308
|
+
mergedInclude = {};
|
|
309
|
+
for (const [relName, relArgs] of Object.entries(include)) {
|
|
310
|
+
if (relName === "_count") {
|
|
311
|
+
// Pass through _count unchanged
|
|
312
|
+
mergedInclude[relName] = relArgs;
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Find the relation's model
|
|
317
|
+
const relField = modelMeta.relationFields.find((r) => r.name === relName);
|
|
318
|
+
if (!relField) {
|
|
319
|
+
mergedInclude[relName] = relArgs;
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const relatedModelMeta = modelMap.get(relField.relatedModel);
|
|
324
|
+
if (!relatedModelMeta) {
|
|
325
|
+
mergedInclude[relName] = relArgs;
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Find view fields for the related model
|
|
330
|
+
const relatedViewKey = findModelKeyInMeta({
|
|
331
|
+
modelName: relField.relatedModel,
|
|
332
|
+
allModelsMeta,
|
|
333
|
+
});
|
|
334
|
+
const relatedViewFields = relatedViewKey
|
|
335
|
+
? viewDefinition[relatedViewKey]
|
|
336
|
+
: undefined;
|
|
337
|
+
|
|
338
|
+
if (!relatedViewFields) {
|
|
339
|
+
// Relation not in view definition — pass through unchanged
|
|
340
|
+
mergedInclude[relName] = relArgs;
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (relArgs === true) {
|
|
345
|
+
// include: { posts: true } → include: { posts: { select: <viewFields> } }
|
|
346
|
+
mergedInclude[relName] = { select: { ...relatedViewFields } };
|
|
347
|
+
} else if (typeof relArgs === "object" && relArgs !== null) {
|
|
348
|
+
const nestedArgs = relArgs as Record<string, unknown>;
|
|
349
|
+
const nestedReveal = (nestedArgs.reveal ?? {}) as Record<string, boolean>;
|
|
350
|
+
const nestedRevealedFields: Record<string, true> = {};
|
|
351
|
+
for (const [key, val] of Object.entries(nestedReveal)) {
|
|
352
|
+
if (val === true) {
|
|
353
|
+
nestedRevealedFields[key] = true;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Build nested select from view fields + nested reveal
|
|
358
|
+
const nestedSelect: Record<string, true> = { ...relatedViewFields };
|
|
359
|
+
for (const key of Object.keys(nestedRevealedFields)) {
|
|
360
|
+
nestedSelect[key] = true;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Preserve other nested args (where, orderBy, take, skip, etc.)
|
|
364
|
+
const preservedArgs: Record<string, unknown> = {};
|
|
365
|
+
for (const [key, val] of Object.entries(nestedArgs)) {
|
|
366
|
+
if (key !== "reveal" && key !== "select") {
|
|
367
|
+
preservedArgs[key] = val;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Handle nested includes recursively
|
|
372
|
+
if (nestedArgs.include) {
|
|
373
|
+
const nestedInclude = mergeViewArgs({
|
|
374
|
+
args: { include: nestedArgs.include },
|
|
375
|
+
viewFields: relatedViewFields,
|
|
376
|
+
viewDefinition,
|
|
377
|
+
modelMeta: relatedModelMeta,
|
|
378
|
+
allModelsMeta,
|
|
379
|
+
});
|
|
380
|
+
preservedArgs.include = (nestedInclude.mergedArgs as Record<string, unknown>).include;
|
|
381
|
+
// When there's a nested include, DON'T use select — use include + omit approach
|
|
382
|
+
// Actually, select and include conflict. If there's a nested include,
|
|
383
|
+
// we need to let include handle it and not use select
|
|
384
|
+
mergedInclude[relName] = {
|
|
385
|
+
...preservedArgs,
|
|
386
|
+
};
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
mergedInclude[relName] = {
|
|
391
|
+
select: nestedSelect,
|
|
392
|
+
...preservedArgs,
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Build the merged args
|
|
399
|
+
const mergedArgs: Record<string, unknown> = {};
|
|
400
|
+
|
|
401
|
+
// Copy through non-view-specific args
|
|
402
|
+
for (const [key, val] of Object.entries(args)) {
|
|
403
|
+
if (key !== "select" && key !== "reveal" && key !== "include" && key !== "omit") {
|
|
404
|
+
mergedArgs[key] = val;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Set the effective select
|
|
409
|
+
mergedArgs.select = effectiveSelect;
|
|
410
|
+
|
|
411
|
+
// Set include if present
|
|
412
|
+
if (mergedInclude && Object.keys(mergedInclude).length > 0) {
|
|
413
|
+
// When we have both select and include, we need to also include relation
|
|
414
|
+
// keys in the select so they don't get filtered out
|
|
415
|
+
for (const relName of Object.keys(mergedInclude)) {
|
|
416
|
+
(mergedArgs.select as Record<string, unknown>)[relName] = mergedInclude[relName];
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return { mergedArgs, revealedFields };
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// ─── Zod Safety Net ───────────────────────────────────────────────
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Build a picked Zod schema that only allows the specified fields.
|
|
427
|
+
* Uses Zod v4's .pick() method via duck-typing.
|
|
428
|
+
*/
|
|
429
|
+
export function buildPickedSchema(params: {
|
|
430
|
+
schema: ValidationSchema;
|
|
431
|
+
fields: Record<string, true>;
|
|
432
|
+
}): ValidationSchema | undefined {
|
|
433
|
+
const { schema, fields } = params;
|
|
434
|
+
|
|
435
|
+
// Duck-type check: does the schema have .pick()?
|
|
436
|
+
const schemaWithPick = schema as unknown as {
|
|
437
|
+
pick?: (fields: Record<string, true>) => ValidationSchema;
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
if (typeof schemaWithPick.pick === "function") {
|
|
441
|
+
try {
|
|
442
|
+
return schemaWithPick.pick(fields);
|
|
443
|
+
} catch {
|
|
444
|
+
// If .pick() fails (e.g., invalid fields), fall back to no safety net
|
|
445
|
+
return undefined;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
return undefined;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Validate records through the view's picked schema.
|
|
454
|
+
* Returns parsed data (with extra fields stripped) on success.
|
|
455
|
+
* On failure, returns the original records (defense-in-depth, not critical path).
|
|
456
|
+
*/
|
|
457
|
+
export function applyViewSchemaFilter(params: {
|
|
458
|
+
records: Record<string, unknown>[];
|
|
459
|
+
viewSchema: ValidationSchema | undefined;
|
|
460
|
+
}): Record<string, unknown>[] {
|
|
461
|
+
const { records, viewSchema } = params;
|
|
462
|
+
if (!viewSchema) return records;
|
|
463
|
+
|
|
464
|
+
return records.map((record) => {
|
|
465
|
+
const result = viewSchema.safeParse(record);
|
|
466
|
+
if (result.success) {
|
|
467
|
+
return result.data as Record<string, unknown>;
|
|
468
|
+
}
|
|
469
|
+
// Safety net failed — return original record (the ViewResult wrapper
|
|
470
|
+
// will still restrict what's visible)
|
|
471
|
+
return record;
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// ─── View Delegate Creation ───────────────────────────────────────
|
|
476
|
+
|
|
477
|
+
/** Read operations exposed on view delegates. */
|
|
478
|
+
const VIEW_READ_OPS = [
|
|
479
|
+
"findMany",
|
|
480
|
+
"findFirst",
|
|
481
|
+
"findUnique",
|
|
482
|
+
"findUniqueOrThrow",
|
|
483
|
+
"findFirstOrThrow",
|
|
484
|
+
"count",
|
|
485
|
+
"aggregate",
|
|
486
|
+
"groupBy",
|
|
487
|
+
] as const;
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Create a view delegate that wraps an existing model delegate.
|
|
491
|
+
* Only read operations are exposed; writes are omitted.
|
|
492
|
+
*/
|
|
493
|
+
export function createViewDelegate(params: {
|
|
494
|
+
delegate: Record<string, Function>;
|
|
495
|
+
modelMeta: ModelMeta;
|
|
496
|
+
viewFields: Record<string, true>;
|
|
497
|
+
viewDefinition: ViewDefinition;
|
|
498
|
+
allModelsMeta: ModelMetaMap;
|
|
499
|
+
modelSchema?: ValidationSchema;
|
|
500
|
+
}): Record<string, Function> {
|
|
501
|
+
const { delegate, modelMeta, viewFields, viewDefinition, allModelsMeta, modelSchema } = params;
|
|
502
|
+
|
|
503
|
+
// Build the picked schema eagerly (once per view, not per query)
|
|
504
|
+
const viewSchema = modelSchema
|
|
505
|
+
? buildPickedSchema({ schema: modelSchema, fields: viewFields })
|
|
506
|
+
: undefined;
|
|
507
|
+
|
|
508
|
+
const viewDelegate: Record<string, Function> = {};
|
|
509
|
+
|
|
510
|
+
// count, aggregate, groupBy — pass through unchanged
|
|
511
|
+
for (const op of ["count", "aggregate", "groupBy"] as const) {
|
|
512
|
+
if (typeof delegate[op] === "function") {
|
|
513
|
+
viewDelegate[op] = delegate[op]!.bind(delegate);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Read operations that return model instances — wrap with view logic
|
|
518
|
+
for (const op of ["findMany", "findFirst", "findUnique", "findUniqueOrThrow", "findFirstOrThrow"] as const) {
|
|
519
|
+
const originalFn = delegate[op];
|
|
520
|
+
if (typeof originalFn !== "function") continue;
|
|
521
|
+
|
|
522
|
+
viewDelegate[op] = async function (args: Record<string, unknown> = {}): Promise<unknown> {
|
|
523
|
+
// Merge view fields + reveal into args
|
|
524
|
+
const { mergedArgs, revealedFields } = mergeViewArgs({
|
|
525
|
+
args,
|
|
526
|
+
viewFields,
|
|
527
|
+
viewDefinition,
|
|
528
|
+
modelMeta,
|
|
529
|
+
allModelsMeta,
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
// Call the original delegate method with merged args
|
|
533
|
+
const rawResult = await originalFn.call(delegate, mergedArgs);
|
|
534
|
+
|
|
535
|
+
// Handle null/undefined (findFirst, findUnique)
|
|
536
|
+
if (rawResult === null || rawResult === undefined) {
|
|
537
|
+
return rawResult;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// Build the query-specific schema (view fields + revealed fields)
|
|
541
|
+
let querySchema = viewSchema;
|
|
542
|
+
if (Object.keys(revealedFields).length > 0 && modelSchema) {
|
|
543
|
+
const allFields = { ...viewFields, ...revealedFields };
|
|
544
|
+
querySchema = buildPickedSchema({ schema: modelSchema, fields: allFields }) ?? viewSchema;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// Wrap results
|
|
548
|
+
if (Array.isArray(rawResult)) {
|
|
549
|
+
// Apply Zod safety net first
|
|
550
|
+
const filtered = applyViewSchemaFilter({
|
|
551
|
+
records: rawResult as Record<string, unknown>[],
|
|
552
|
+
viewSchema: querySchema,
|
|
553
|
+
});
|
|
554
|
+
return wrapResultsWithView({
|
|
555
|
+
records: filtered,
|
|
556
|
+
viewFields,
|
|
557
|
+
revealedFields,
|
|
558
|
+
viewDefinition,
|
|
559
|
+
modelMeta,
|
|
560
|
+
allModelsMeta,
|
|
561
|
+
});
|
|
562
|
+
} else {
|
|
563
|
+
// Single result (findUniqueOrThrow, findFirstOrThrow)
|
|
564
|
+
const filtered = applyViewSchemaFilter({
|
|
565
|
+
records: [rawResult as Record<string, unknown>],
|
|
566
|
+
viewSchema: querySchema,
|
|
567
|
+
});
|
|
568
|
+
const wrapped = wrapResultsWithView({
|
|
569
|
+
records: filtered,
|
|
570
|
+
viewFields,
|
|
571
|
+
revealedFields,
|
|
572
|
+
viewDefinition,
|
|
573
|
+
modelMeta,
|
|
574
|
+
allModelsMeta,
|
|
575
|
+
});
|
|
576
|
+
return wrapped[0] ?? null;
|
|
577
|
+
}
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
return viewDelegate;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// ─── View Client Creation ─────────────────────────────────────────
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Create a view client from an existing client and a view definition.
|
|
588
|
+
* Returns an object with only the models specified in the definition,
|
|
589
|
+
* each with read-only delegates restricted to view fields.
|
|
590
|
+
*/
|
|
591
|
+
export function createView(params: {
|
|
592
|
+
client: Record<string, unknown>;
|
|
593
|
+
name: string;
|
|
594
|
+
definition: ViewDefinition;
|
|
595
|
+
allModelsMeta: ModelMetaMap;
|
|
596
|
+
schemas?: Record<string, ModelSchemas>;
|
|
597
|
+
}): Record<string, unknown> {
|
|
598
|
+
const { client, name, definition, allModelsMeta, schemas } = params;
|
|
599
|
+
|
|
600
|
+
const viewClient: Record<string, unknown> = {};
|
|
601
|
+
|
|
602
|
+
for (const [modelKey, viewFields] of Object.entries(definition)) {
|
|
603
|
+
const delegate = client[modelKey] as Record<string, Function> | undefined;
|
|
604
|
+
if (!delegate || typeof delegate !== "object") {
|
|
605
|
+
throw new Error(
|
|
606
|
+
`[vibeorm] View "${name}": model "${modelKey}" not found on the client. ` +
|
|
607
|
+
`Available models: ${Object.keys(allModelsMeta).join(", ")}`
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const modelMeta = allModelsMeta[modelKey];
|
|
612
|
+
if (!modelMeta) {
|
|
613
|
+
throw new Error(
|
|
614
|
+
`[vibeorm] View "${name}": no metadata found for model "${modelKey}".`
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
viewClient[modelKey] = createViewDelegate({
|
|
619
|
+
delegate,
|
|
620
|
+
modelMeta,
|
|
621
|
+
viewFields,
|
|
622
|
+
viewDefinition: definition,
|
|
623
|
+
allModelsMeta,
|
|
624
|
+
modelSchema: schemas?.[modelKey]?.model,
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
return viewClient;
|
|
629
|
+
}
|