@harperfast/harper 5.2.0-alpha.4 → 5.2.0-alpha.6
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/agent/agent.ts +152 -10
- package/agent/bestPractices.ts +58 -0
- package/agent/mcpTools.ts +122 -0
- package/agent/operations.ts +10 -1
- package/agent/registryTools.ts +117 -0
- package/agent/session.ts +16 -7
- package/agent/tools/fsTools.ts +74 -27
- package/agent/tools/inspectorTool.ts +459 -0
- package/agent/toolset.ts +27 -7
- package/agent/types.ts +2 -0
- package/components/Application.ts +566 -85
- package/components/OptionsWatcher.ts +62 -2
- package/components/Scope.ts +31 -8
- package/components/componentLoader.ts +5 -1
- package/components/componentSecrets.ts +514 -55
- package/components/deploymentRecorder.ts +7 -6
- package/components/gitCredentialHelper.js +115 -0
- package/components/gitCredentialServer.ts +251 -0
- package/components/mcp/tools/application.ts +197 -23
- package/components/mcp/tools/operations.ts +1 -1
- package/components/mcp/tools/schemas/derive.ts +6 -2
- package/components/operations.js +21 -19
- package/components/operationsValidation.js +86 -21
- package/components/secretOperations.ts +110 -30
- package/config/harperConfigEnvVars.ts +90 -5
- package/config-root.schema.json +4 -0
- package/dist/agent/agent.d.ts +24 -3
- package/dist/agent/agent.js +172 -10
- package/dist/agent/agent.js.map +1 -1
- package/dist/agent/bestPractices.d.ts +24 -0
- package/dist/agent/bestPractices.js +60 -0
- package/dist/agent/bestPractices.js.map +1 -0
- package/dist/agent/mcpTools.d.ts +24 -0
- package/dist/agent/mcpTools.js +115 -0
- package/dist/agent/mcpTools.js.map +1 -0
- package/dist/agent/operations.js +10 -1
- package/dist/agent/operations.js.map +1 -1
- package/dist/agent/registryTools.d.ts +45 -0
- package/dist/agent/registryTools.js +113 -0
- package/dist/agent/registryTools.js.map +1 -0
- package/dist/agent/session.js +16 -7
- package/dist/agent/session.js.map +1 -1
- package/dist/agent/tools/fsTools.js +70 -28
- package/dist/agent/tools/fsTools.js.map +1 -1
- package/dist/agent/tools/inspectorTool.d.ts +40 -0
- package/dist/agent/tools/inspectorTool.js +428 -0
- package/dist/agent/tools/inspectorTool.js.map +1 -0
- package/dist/agent/toolset.d.ts +16 -6
- package/dist/agent/toolset.js +17 -7
- package/dist/agent/toolset.js.map +1 -1
- package/dist/agent/types.d.ts +2 -0
- package/dist/components/Application.d.ts +73 -18
- package/dist/components/Application.js +475 -64
- package/dist/components/Application.js.map +1 -1
- package/dist/components/OptionsWatcher.d.ts +1 -1
- package/dist/components/OptionsWatcher.js +65 -2
- package/dist/components/OptionsWatcher.js.map +1 -1
- package/dist/components/Scope.d.ts +8 -6
- package/dist/components/Scope.js +22 -6
- package/dist/components/Scope.js.map +1 -1
- package/dist/components/componentLoader.js +5 -1
- package/dist/components/componentLoader.js.map +1 -1
- package/dist/components/componentSecrets.d.ts +33 -7
- package/dist/components/componentSecrets.js +473 -59
- package/dist/components/componentSecrets.js.map +1 -1
- package/dist/components/deploymentRecorder.d.ts +2 -2
- package/dist/components/deploymentRecorder.js +1 -1
- package/dist/components/deploymentRecorder.js.map +1 -1
- package/dist/components/gitCredentialHelper.d.ts +1 -0
- package/dist/components/gitCredentialHelper.js +113 -0
- package/dist/components/gitCredentialHelper.js.map +1 -0
- package/dist/components/gitCredentialServer.d.ts +33 -0
- package/dist/components/gitCredentialServer.js +236 -0
- package/dist/components/gitCredentialServer.js.map +1 -0
- package/dist/components/mcp/tools/application.d.ts +19 -0
- package/dist/components/mcp/tools/application.js +180 -22
- package/dist/components/mcp/tools/application.js.map +1 -1
- package/dist/components/mcp/tools/operations.d.ts +13 -0
- package/dist/components/mcp/tools/operations.js +1 -0
- package/dist/components/mcp/tools/operations.js.map +1 -1
- package/dist/components/mcp/tools/schemas/derive.js +6 -2
- package/dist/components/mcp/tools/schemas/derive.js.map +1 -1
- package/dist/components/operations.js +23 -21
- package/dist/components/operations.js.map +1 -1
- package/dist/components/operationsValidation.js +84 -21
- package/dist/components/operationsValidation.js.map +1 -1
- package/dist/components/secretOperations.d.ts +33 -11
- package/dist/components/secretOperations.js +90 -26
- package/dist/components/secretOperations.js.map +1 -1
- package/dist/config/harperConfigEnvVars.d.ts +21 -0
- package/dist/config/harperConfigEnvVars.js +95 -5
- package/dist/config/harperConfigEnvVars.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.js +14 -1
- package/dist/index.js.map +1 -1
- package/dist/resources/Resource.d.ts +19 -0
- package/dist/resources/Resource.js +93 -2
- package/dist/resources/Resource.js.map +1 -1
- package/dist/resources/Table.d.ts +9 -0
- package/dist/resources/Table.js +256 -50
- package/dist/resources/Table.js.map +1 -1
- package/dist/resources/analytics/read.js +28 -25
- package/dist/resources/analytics/read.js.map +1 -1
- package/dist/resources/defineResource.d.ts +180 -0
- package/dist/resources/defineResource.js +505 -0
- package/dist/resources/defineResource.js.map +1 -0
- package/dist/resources/defineTable.d.ts +221 -0
- package/dist/resources/defineTable.js +227 -0
- package/dist/resources/defineTable.js.map +1 -0
- package/dist/resources/jsonSchemaTypes.d.ts +2 -0
- package/dist/resources/jsonSchemaTypes.js +12 -4
- package/dist/resources/jsonSchemaTypes.js.map +1 -1
- package/dist/resources/openApi.js +69 -13
- package/dist/resources/openApi.js.map +1 -1
- package/dist/resources/search.js +6 -8
- package/dist/resources/search.js.map +1 -1
- package/dist/server/graphqlQuerying.js +4 -2
- package/dist/server/graphqlQuerying.js.map +1 -1
- package/dist/server/http.d.ts +12 -0
- package/dist/server/http.js +35 -15
- package/dist/server/http.js.map +1 -1
- package/dist/server/serverHelpers/serverUtilities.js +8 -5
- package/dist/server/serverHelpers/serverUtilities.js.map +1 -1
- package/dist/server/serverHelpers/uwsServer.js +23 -0
- package/dist/server/serverHelpers/uwsServer.js.map +1 -1
- package/dist/utility/errors/hdbError.d.ts +21 -0
- package/dist/utility/errors/hdbError.js +23 -1
- package/dist/utility/errors/hdbError.js.map +1 -1
- package/dist/validation/configValidator.js +22 -6
- package/dist/validation/configValidator.js.map +1 -1
- package/index.ts +33 -0
- package/npm-shrinkwrap.json +9197 -15236
- package/package.json +5 -3
- package/resources/DESIGN.md +42 -15
- package/resources/Resource.ts +99 -2
- package/resources/Table.ts +275 -71
- package/resources/analytics/read.ts +30 -25
- package/resources/defineResource.ts +651 -0
- package/resources/defineTable.ts +407 -0
- package/resources/jsonSchemaTypes.ts +12 -4
- package/resources/openApi.ts +68 -16
- package/resources/search.ts +5 -8
- package/server/graphqlQuerying.ts +4 -2
- package/server/http.ts +40 -16
- package/server/serverHelpers/serverUtilities.ts +19 -6
- package/server/serverHelpers/uwsServer.ts +24 -0
- package/studio/web/assets/{Chat-D4FIaBu7.js → Chat-CTjtL8Z4.js} +2 -2
- package/studio/web/assets/{Chat-D4FIaBu7.js.map → Chat-CTjtL8Z4.js.map} +1 -1
- package/studio/web/assets/{FloatingChat-DGUC3L4r.js → FloatingChat-CafHR4Ur.js} +4 -4
- package/studio/web/assets/{FloatingChat-DGUC3L4r.js.map → FloatingChat-CafHR4Ur.js.map} +1 -1
- package/studio/web/assets/{applications-D8Am9ikM.js → applications-Buh_q0Vj.js} +2 -2
- package/studio/web/assets/{applications-D8Am9ikM.js.map → applications-Buh_q0Vj.js.map} +1 -1
- package/studio/web/assets/{index-HGL4WHdb.js → index-0hXeECkS.js} +6 -6
- package/studio/web/assets/{index-HGL4WHdb.js.map → index-0hXeECkS.js.map} +1 -1
- package/studio/web/assets/{index.lazy-Dk5j1AoQ.js → index.lazy-B00B7VBT.js} +4 -4
- package/studio/web/assets/{index.lazy-Dk5j1AoQ.js.map → index.lazy-B00B7VBT.js.map} +1 -1
- package/studio/web/assets/{profile-7bu_CF1f.js → profile-Cg2wwYPn.js} +2 -2
- package/studio/web/assets/{profile-7bu_CF1f.js.map → profile-Cg2wwYPn.js.map} +1 -1
- package/studio/web/assets/{setComponentFile-MfYm9F94.js → setComponentFile-DCaDIvyB.js} +2 -2
- package/studio/web/assets/{setComponentFile-MfYm9F94.js.map → setComponentFile-DCaDIvyB.js.map} +1 -1
- package/studio/web/assets/{setup-SKA4UhdE.js → setup-CAVcAQjK.js} +2 -2
- package/studio/web/assets/{setup-SKA4UhdE.js.map → setup-CAVcAQjK.js.map} +1 -1
- package/studio/web/assets/{status-DetVLUxR.js → status-BRXorNdD.js} +2 -2
- package/studio/web/assets/{status-DetVLUxR.js.map → status-BRXorNdD.js.map} +1 -1
- package/studio/web/assets/{swagger-ui-react-DLeYARY2.js → swagger-ui-react-Dy1D62vO.js} +2 -2
- package/studio/web/assets/{swagger-ui-react-DLeYARY2.js.map → swagger-ui-react-Dy1D62vO.js.map} +1 -1
- package/studio/web/assets/{tsMode-DGO-jIF7.js → tsMode-A8gbL74v.js} +2 -2
- package/studio/web/assets/{tsMode-DGO-jIF7.js.map → tsMode-A8gbL74v.js.map} +1 -1
- package/studio/web/assets/{useEntityRestURL-4M2i6bo4.js → useEntityRestURL-cDodrVcQ.js} +2 -2
- package/studio/web/assets/{useEntityRestURL-4M2i6bo4.js.map → useEntityRestURL-cDodrVcQ.js.map} +1 -1
- package/studio/web/index.html +1 -1
- package/utility/errors/hdbError.ts +32 -0
- package/validation/configValidator.ts +23 -6
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Code-first schema authoring (`defineTable` + `types`), the canonical model.
|
|
3
|
+
*
|
|
4
|
+
* The dev names exactly ONE thing — `Track` (singular). `defineTable` eagerly registers the
|
|
5
|
+
* table through the same `table()` factory GraphQL drives (`resources/databases.ts`) and returns
|
|
6
|
+
* the live, registered class: `Track.get/put/post/patch/update/query/search` work immediately,
|
|
7
|
+
* `new Track()` / `instanceof Track` hold, and every per-verb shape is a *projection* inferred
|
|
8
|
+
* from the one definition, discoverable as members (`Track.$record`, `Track.$insert`, …) — never
|
|
9
|
+
* a parallel name.
|
|
10
|
+
*
|
|
11
|
+
* The anchoring constraint (`--conditions=typestrip`): runtime metadata is carried as values;
|
|
12
|
+
* TypeScript types are *derived* from those values. Everything here is erasable syntax — the
|
|
13
|
+
* values survive type-stripping, the types are inferred, nothing can drift.
|
|
14
|
+
*
|
|
15
|
+
* Both authoring front-ends compile to the same typeDef shape (`{ table, database, attributes,
|
|
16
|
+
* properties, schemaDefined }`), so DDL/migration semantics are shared rather than reimplemented.
|
|
17
|
+
* Relationships take a lazy thunk (`types.relation(() => Album, { from: 'albumId' })`); the
|
|
18
|
+
* target class is resolved on first use (the same late-binding contract GraphQL's
|
|
19
|
+
* `connectPropertyType` relies on — `definition` must exist at registration, `definition.tableClass`
|
|
20
|
+
* only by query time), so forward references and cycles between tables just work AT RUNTIME.
|
|
21
|
+
*
|
|
22
|
+
* A truly MUTUAL pair (`Book.author` -> `Author`, `Author.books` -> `Book`) is a different problem
|
|
23
|
+
* at the TYPE level: TypeScript's const-initializer inference is eager, so `typeof Book` and
|
|
24
|
+
* `typeof Author` end up depending on each other and both collapse to `any` (TS7022). `relationOf`
|
|
25
|
+
* / `hasManyOf` are the escape hatch — see their doc comments below.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { table, type Table } from './databases.ts';
|
|
29
|
+
import { attributeToFragment } from './jsonSchemaTypes.ts';
|
|
30
|
+
|
|
31
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
32
|
+
// Field model — phantom-typed Field. Flags are set via GETTER PROPERTIES (no call):
|
|
33
|
+
// `string.indexed` / `id.primaryKey` / `date.createdTime` narrow the type-level flags.
|
|
34
|
+
// Only arg-taking builders (`enum([...])`, `relation(() => T)`) remain calls.
|
|
35
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
export interface Flags {
|
|
38
|
+
nullable?: boolean;
|
|
39
|
+
primaryKey?: boolean;
|
|
40
|
+
serverManaged?: boolean; // @createdTime / @updatedTime — never client-writable
|
|
41
|
+
indexed?: boolean; // queryable in the filter grammar
|
|
42
|
+
relation?: 'one' | 'many';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A schema field. `_ts`/`_flags` are phantom type carriers (never present at runtime); the
|
|
47
|
+
* flag getters return a fresh field with the flag applied at both the value and type level.
|
|
48
|
+
*/
|
|
49
|
+
export interface Field<TS, F extends Flags = {}> {
|
|
50
|
+
readonly _ts: TS;
|
|
51
|
+
readonly _flags: F;
|
|
52
|
+
readonly kind: string;
|
|
53
|
+
readonly meta: Record<string, unknown>;
|
|
54
|
+
readonly nullable: Field<TS, F & { nullable: true }>;
|
|
55
|
+
readonly indexed: Field<TS, F & { indexed: true }>;
|
|
56
|
+
readonly primaryKey: Field<TS, F & { primaryKey: true }>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Date additionally carries the server-managed timestamp flags. */
|
|
60
|
+
export interface DateField<F extends Flags = {}> extends Field<Date, F> {
|
|
61
|
+
readonly createdTime: Field<Date, F & { serverManaged: true }>;
|
|
62
|
+
readonly updatedTime: Field<Date, F & { serverManaged: true }>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The read-record type of a related table handle. */
|
|
66
|
+
type RecordOf<T> = T extends { readonly $record: infer R } ? R : never;
|
|
67
|
+
|
|
68
|
+
// to-one resolves to the related record; to-many to an array of it.
|
|
69
|
+
export type RelationField<Target, Card extends 'one' | 'many'> = Field<
|
|
70
|
+
Card extends 'many' ? RecordOf<Target>[] : RecordOf<Target>,
|
|
71
|
+
{ relation: Card }
|
|
72
|
+
>;
|
|
73
|
+
|
|
74
|
+
// Runtime builders — getters return FRESH fields (immutable chaining; no shared-state bug).
|
|
75
|
+
function makeField(kind: string, meta: Record<string, unknown> = {}): any {
|
|
76
|
+
return {
|
|
77
|
+
kind,
|
|
78
|
+
meta,
|
|
79
|
+
get nullable() {
|
|
80
|
+
return makeField(kind, { ...meta, nullable: true });
|
|
81
|
+
},
|
|
82
|
+
get indexed() {
|
|
83
|
+
return makeField(kind, { ...meta, indexed: true });
|
|
84
|
+
},
|
|
85
|
+
get primaryKey() {
|
|
86
|
+
return makeField(kind, { ...meta, primaryKey: true });
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
function makeDateField(meta: Record<string, unknown> = {}): any {
|
|
91
|
+
return Object.defineProperties(makeField('Date', meta), {
|
|
92
|
+
createdTime: {
|
|
93
|
+
get: () => makeField('Date', { ...meta, assignCreatedTime: true }),
|
|
94
|
+
enumerable: true,
|
|
95
|
+
configurable: true,
|
|
96
|
+
},
|
|
97
|
+
updatedTime: {
|
|
98
|
+
get: () => makeField('Date', { ...meta, assignUpdatedTime: true }),
|
|
99
|
+
enumerable: true,
|
|
100
|
+
configurable: true,
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The field vocabulary the dev destructures: `const { id, string, int, date } = types`.
|
|
107
|
+
* Pure-flag builders are values (getter chains); only arg-taking builders stay functions.
|
|
108
|
+
* Every member mirrors a GraphQL directive/type so the two front-ends stay in lockstep.
|
|
109
|
+
*/
|
|
110
|
+
export const types = {
|
|
111
|
+
id: makeField('ID') as Field<string>,
|
|
112
|
+
string: makeField('String') as Field<string>,
|
|
113
|
+
int: makeField('Int') as Field<number>,
|
|
114
|
+
float: makeField('Float') as Field<number>,
|
|
115
|
+
long: makeField('Long') as Field<number>,
|
|
116
|
+
boolean: makeField('Boolean') as Field<boolean>,
|
|
117
|
+
date: makeDateField() as DateField,
|
|
118
|
+
bytes: makeField('Bytes') as Field<Uint8Array>,
|
|
119
|
+
any: makeField('Any') as Field<unknown>,
|
|
120
|
+
// Narrows the TS type to the literal union; stored as a String column. The narrowing is
|
|
121
|
+
// advisory at runtime today (the shared Table.validate has no enum case — same as GraphQL);
|
|
122
|
+
// the literal set is retained on the attribute for downstream surfaces.
|
|
123
|
+
enum: <const E extends readonly string[]>(values: E) => makeField('String', { enum: values }) as Field<E[number]>,
|
|
124
|
+
// many-to-one: this table holds the foreign key named `from`. Lazy thunk — forward refs are fine.
|
|
125
|
+
relation: <T>(target: () => T, opts: { from: string }) =>
|
|
126
|
+
makeField('relation', { target, from: opts.from }) as RelationField<T, 'one'>,
|
|
127
|
+
// one-to-many: the related table holds the foreign key named `to`.
|
|
128
|
+
hasMany: <T>(target: () => T, opts: { to: string }) =>
|
|
129
|
+
makeField('relation', { target, to: opts.to }) as RelationField<T, 'many'>,
|
|
130
|
+
/**
|
|
131
|
+
* `relation`'s escape hatch for a MUTUAL pair (`Book.author` <-> `Author.books`): TS's
|
|
132
|
+
* const-initializer inference is eager, so `typeof Book` and `typeof Author` would each depend
|
|
133
|
+
* on the other and both collapse to `any` (TS7022) if both sides used `relation`/`hasMany`.
|
|
134
|
+
* `relationOf<R>` breaks the cycle — `target` is typed `() => any` (no inference is pulled from
|
|
135
|
+
* the argument), and the related RECORD shape is supplied explicitly via `<R>`. Declare a small
|
|
136
|
+
* interface for the record once, on whichever side of the pair you close second; the runtime
|
|
137
|
+
* behavior (lazy thunk, resolved on first query-time use) is identical to `relation`.
|
|
138
|
+
*/
|
|
139
|
+
relationOf: <R>(target: () => any, opts: { from: string }) =>
|
|
140
|
+
makeField('relation', { target, from: opts.from }) as RelationField<{ readonly $record: R }, 'one'>,
|
|
141
|
+
/** `hasMany`'s escape hatch for a mutual pair — see `relationOf`. */
|
|
142
|
+
hasManyOf: <R>(target: () => any, opts: { to: string }) =>
|
|
143
|
+
makeField('relation', { target, to: opts.to }) as RelationField<{ readonly $record: R }, 'many'>,
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
export type Shape = Record<string, Field<any, any>>;
|
|
147
|
+
|
|
148
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
149
|
+
// Flag predicates
|
|
150
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
type TsOf<F> = F extends Field<infer TS, any> ? TS : never;
|
|
153
|
+
type FlagsOf<F> = F extends Field<any, infer Fl> ? Fl : {};
|
|
154
|
+
type IsNullable<F> = FlagsOf<F> extends { nullable: true } ? true : false;
|
|
155
|
+
type IsPrimaryKey<F> = FlagsOf<F> extends { primaryKey: true } ? true : false;
|
|
156
|
+
type IsServerManaged<F> = FlagsOf<F> extends { serverManaged: true } ? true : false;
|
|
157
|
+
type IsIndexed<F> = FlagsOf<F> extends { indexed: true } ? true : false;
|
|
158
|
+
type IsRelation<F> = FlagsOf<F> extends { relation: 'one' | 'many' } ? true : false;
|
|
159
|
+
/** Server-managed and relation fields are read-only on the record. */
|
|
160
|
+
type IsReadOnly<F> = IsServerManaged<F> extends true ? true : IsRelation<F>;
|
|
161
|
+
|
|
162
|
+
// Flatten an intersection of mapped types into one object, preserving readonly/optional modifiers.
|
|
163
|
+
type Resolve<T> = { [K in keyof T]: T[K] };
|
|
164
|
+
|
|
165
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
166
|
+
// The projections — every one derived from the SAME shape S.
|
|
167
|
+
// nullable field -> optional property
|
|
168
|
+
// serverManaged / relation -> present on reads (readonly), absent on all writes
|
|
169
|
+
// primaryKey -> required to read back / upsert, optional to insert, never patched
|
|
170
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
/** The record's data fields: server-managed/relations readonly, others mutable; nullable -> optional. */
|
|
173
|
+
type RecordData<S> = Resolve<
|
|
174
|
+
{
|
|
175
|
+
readonly [K in keyof S as IsReadOnly<S[K]> extends true ? K : never]: TsOf<S[K]>;
|
|
176
|
+
} & {
|
|
177
|
+
[K in keyof S as IsReadOnly<S[K]> extends false ? (IsNullable<S[K]> extends false ? K : never) : never]: TsOf<S[K]>;
|
|
178
|
+
} & {
|
|
179
|
+
[K in keyof S as IsReadOnly<S[K]> extends false ? (IsNullable<S[K]> extends true ? K : never) : never]?: TsOf<S[K]>;
|
|
180
|
+
}
|
|
181
|
+
>;
|
|
182
|
+
|
|
183
|
+
// A field is client-writable unless the server manages it or it is a relation projection.
|
|
184
|
+
type WritableOf<S> = {
|
|
185
|
+
[K in keyof S as IsServerManaged<S[K]> extends false ? (IsRelation<S[K]> extends false ? K : never) : never]: S[K];
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
/** INSERT (POST): writable fields; PK OPTIONAL (server can generate it); nullable -> optional. */
|
|
189
|
+
type InsertOf<S, W = WritableOf<S>> = Resolve<
|
|
190
|
+
{
|
|
191
|
+
[K in keyof W as IsPrimaryKey<W[K]> extends false ? (IsNullable<W[K]> extends false ? K : never) : never]: TsOf<
|
|
192
|
+
W[K]
|
|
193
|
+
>;
|
|
194
|
+
} & {
|
|
195
|
+
[K in keyof W as IsPrimaryKey<W[K]> extends true ? K : IsNullable<W[K]> extends true ? K : never]?: TsOf<W[K]>;
|
|
196
|
+
}
|
|
197
|
+
>;
|
|
198
|
+
|
|
199
|
+
/** UPSERT (PUT): full replace — writable fields, PK REQUIRED (you're naming the row). */
|
|
200
|
+
type UpsertOf<S, W = WritableOf<S>> = Resolve<
|
|
201
|
+
{
|
|
202
|
+
[K in keyof W as IsNullable<W[K]> extends false ? K : never]: TsOf<W[K]>;
|
|
203
|
+
} & {
|
|
204
|
+
[K in keyof W as IsNullable<W[K]> extends true ? K : never]?: TsOf<W[K]>;
|
|
205
|
+
}
|
|
206
|
+
>;
|
|
207
|
+
|
|
208
|
+
/** PATCH: partial update — every writable non-PK field optional. */
|
|
209
|
+
type PatchOf<S, W = WritableOf<S>> = Resolve<{
|
|
210
|
+
[K in keyof W as IsPrimaryKey<W[K]> extends true ? never : K]?: TsOf<W[K]>;
|
|
211
|
+
}>;
|
|
212
|
+
|
|
213
|
+
/** QUERY / filter: indexed fields only, all optional (the base filter grammar keys to these). */
|
|
214
|
+
type QueryOf<S> = Resolve<{
|
|
215
|
+
[K in keyof S as IsIndexed<S[K]> extends true ? K : never]?: TsOf<S[K]>;
|
|
216
|
+
}>;
|
|
217
|
+
|
|
218
|
+
/** The read-only response variant — the whole record, nothing writable. */
|
|
219
|
+
type ReadVariant<S> = Readonly<RecordData<S>>;
|
|
220
|
+
|
|
221
|
+
/** The LIVE instance — what `new Track()` / `Track.update()` yield; writable fields mutable. */
|
|
222
|
+
type InstanceOf<S> = RecordData<S>;
|
|
223
|
+
|
|
224
|
+
/** The primary-key property and its value type (falls back to string|number when none declared). */
|
|
225
|
+
type PkKey<S> = { [K in keyof S]: IsPrimaryKey<S[K]> extends true ? K : never }[keyof S];
|
|
226
|
+
type IdOf<S> = [PkKey<S>] extends [never] ? string | number : TsOf<S[PkKey<S>]>;
|
|
227
|
+
|
|
228
|
+
type MaybePromise<T> = T | Promise<T>;
|
|
229
|
+
|
|
230
|
+
// The verbs retyped by the projections. Full query-grammar typing (conditions/sort/select keyed
|
|
231
|
+
// to the record) is future typed-query-behavior work — here only inputs/outputs are narrowed.
|
|
232
|
+
interface TypedVerbs<S extends Shape> {
|
|
233
|
+
get(id: IdOf<S>, context?: any): MaybePromise<ReadVariant<S> | undefined>;
|
|
234
|
+
put(record: UpsertOf<S>, context?: any): MaybePromise<unknown>;
|
|
235
|
+
post(record: InsertOf<S>, context?: any): MaybePromise<unknown>;
|
|
236
|
+
patch(id: IdOf<S>, changes: PatchOf<S>, context?: any): MaybePromise<unknown>;
|
|
237
|
+
update(id: IdOf<S>, updates?: PatchOf<S>, context?: any): MaybePromise<InstanceOf<S>>;
|
|
238
|
+
delete(id: IdOf<S>, context?: any): MaybePromise<unknown>;
|
|
239
|
+
search(query?: any, context?: any): AsyncIterable<ReadVariant<S>>;
|
|
240
|
+
query(query?: any, context?: any): AsyncIterable<ReadVariant<S>>;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The single canonical handle `defineTable` returns: the registered table class, with
|
|
245
|
+
* • the verbs typed by the projections,
|
|
246
|
+
* • phantom `$*` members so each projection is discoverable as `(typeof Track)['$insert']`,
|
|
247
|
+
* • a construct signature so `Track` doubles as the instance type via `InstanceType<typeof Track>`.
|
|
248
|
+
*/
|
|
249
|
+
export type TableHandle<S extends Shape = Shape> = Omit<
|
|
250
|
+
Table,
|
|
251
|
+
'get' | 'put' | 'post' | 'patch' | 'update' | 'delete' | 'search' | 'query'
|
|
252
|
+
> &
|
|
253
|
+
TypedVerbs<S> & {
|
|
254
|
+
// phantom projection carriers — discovery surface, zero runtime cost
|
|
255
|
+
readonly $record: ReadVariant<S>;
|
|
256
|
+
readonly $insert: InsertOf<S>;
|
|
257
|
+
readonly $upsert: UpsertOf<S>;
|
|
258
|
+
readonly $patch: PatchOf<S>;
|
|
259
|
+
readonly $query: QueryOf<S>;
|
|
260
|
+
} & (new (...args: any[]) => InstanceOf<S>);
|
|
261
|
+
|
|
262
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
263
|
+
// Compilation & registration
|
|
264
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
265
|
+
|
|
266
|
+
/** `@table` directive parity — options GraphQL can declare on a type, plus the database. */
|
|
267
|
+
export interface DefineTableOptions {
|
|
268
|
+
/** Database (schema) the table lives in; defaults to the `data` database. */
|
|
269
|
+
database?: string;
|
|
270
|
+
audit?: boolean;
|
|
271
|
+
replicate?: boolean;
|
|
272
|
+
sealed?: boolean;
|
|
273
|
+
expiration?: number;
|
|
274
|
+
eviction?: number;
|
|
275
|
+
scanInterval?: number;
|
|
276
|
+
splitSegments?: boolean;
|
|
277
|
+
trackDeletes?: boolean;
|
|
278
|
+
randomAccessFields?: boolean;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function memoize<T>(fn: () => T): () => T {
|
|
282
|
+
let value: T;
|
|
283
|
+
let resolved = false;
|
|
284
|
+
return () => {
|
|
285
|
+
if (!resolved) {
|
|
286
|
+
value = fn();
|
|
287
|
+
resolved = true;
|
|
288
|
+
}
|
|
289
|
+
return value;
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Compile a shape into the same `{ table, database, attributes, properties }` typeDef
|
|
295
|
+
* `resources/graphql.ts` builds, so registration takes the identical `table()` path.
|
|
296
|
+
*
|
|
297
|
+
* Nullability parity: GraphQL leaves plain fields' `nullable` undefined and marks `!` fields
|
|
298
|
+
* `nullable: false` (it never emits `nullable: true`). We map identically — `.nullable` leaves
|
|
299
|
+
* the attribute unmarked, everything else writable is `nullable: false` — so fragments AND
|
|
300
|
+
* validation semantics match. PK and server-managed fields stay unmarked (the PK machinery and
|
|
301
|
+
* server assignment own their presence; marking them required would reject valid writes).
|
|
302
|
+
*
|
|
303
|
+
* Relations resolve lazily: `definition` exists at registration (the resolver-build contract in
|
|
304
|
+
* Table.ts) but its `tableClass` getter defers the thunk to first use, by which time the target
|
|
305
|
+
* is defined — forward references and cycles are safe.
|
|
306
|
+
*/
|
|
307
|
+
function compileTypeDef(name: string, shape: Shape, options: DefineTableOptions): any {
|
|
308
|
+
const attributes: any[] = [];
|
|
309
|
+
const properties: Record<string, any> = {};
|
|
310
|
+
const declared = new Set(Object.keys(shape));
|
|
311
|
+
|
|
312
|
+
for (const [attrName, f] of Object.entries(shape) as [string, any][]) {
|
|
313
|
+
const meta = f.meta ?? {};
|
|
314
|
+
if (f.kind === 'relation') {
|
|
315
|
+
const resolveClass = memoize(meta.target as () => any);
|
|
316
|
+
// The definition mirrors what GraphQL's connectPropertyType attaches (the target
|
|
317
|
+
// typeDef): `type` and `attributes` feed OpenAPI's includeDefinitionInSchema (so the
|
|
318
|
+
// emitted $ref component is always defined), `tableClass` feeds the query-time
|
|
319
|
+
// resolvers. All lazy — read only after every table is defined.
|
|
320
|
+
const definition = {};
|
|
321
|
+
Object.defineProperty(definition, 'tableClass', { get: resolveClass, enumerable: true, configurable: true });
|
|
322
|
+
Object.defineProperty(definition, 'type', {
|
|
323
|
+
get: () => resolveClass().tableName,
|
|
324
|
+
enumerable: true,
|
|
325
|
+
configurable: true,
|
|
326
|
+
});
|
|
327
|
+
Object.defineProperty(definition, 'attributes', {
|
|
328
|
+
get: () => resolveClass().attributes,
|
|
329
|
+
enumerable: true,
|
|
330
|
+
configurable: true,
|
|
331
|
+
});
|
|
332
|
+
let attr: any;
|
|
333
|
+
if (meta.from) {
|
|
334
|
+
// many-to-one: this table holds the foreign key. Like GraphQL, the FK must be a
|
|
335
|
+
// declared field — that makes it typed, projected into `properties`, and writable
|
|
336
|
+
// in $insert/$upsert (the relation itself is a read-only projection).
|
|
337
|
+
if (!declared.has(meta.from)) {
|
|
338
|
+
throw new Error(
|
|
339
|
+
`Table "${name}": relation "${attrName}" references foreign key "${meta.from}", which must be declared in the shape (e.g. \`${meta.from}: id.indexed\`)`
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
attr = { name: attrName, relationship: { from: meta.from } };
|
|
343
|
+
Object.defineProperty(attr, 'type', {
|
|
344
|
+
get: () => resolveClass().tableName,
|
|
345
|
+
enumerable: true,
|
|
346
|
+
configurable: true,
|
|
347
|
+
});
|
|
348
|
+
// non-enumerable, mirroring GraphQL's connectPropertyType
|
|
349
|
+
Object.defineProperty(attr, 'definition', { value: definition, configurable: true });
|
|
350
|
+
} else {
|
|
351
|
+
// one-to-many: the related table holds the foreign key
|
|
352
|
+
const elements: any = {};
|
|
353
|
+
Object.defineProperty(elements, 'type', {
|
|
354
|
+
get: () => resolveClass().tableName,
|
|
355
|
+
enumerable: true,
|
|
356
|
+
configurable: true,
|
|
357
|
+
});
|
|
358
|
+
Object.defineProperty(elements, 'definition', { value: definition, configurable: true });
|
|
359
|
+
attr = { name: attrName, type: 'array', relationship: { to: meta.to }, elements };
|
|
360
|
+
}
|
|
361
|
+
attributes.push(attr);
|
|
362
|
+
// fragment reads the lazy `type`, so project it lazily too (memoized — cold surface)
|
|
363
|
+
let fragment: any;
|
|
364
|
+
Object.defineProperty(properties, attrName, {
|
|
365
|
+
get: () => (fragment ??= attributeToFragment(attr)),
|
|
366
|
+
enumerable: true,
|
|
367
|
+
configurable: true,
|
|
368
|
+
});
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
const attr: any = { name: attrName, type: f.kind };
|
|
372
|
+
if (meta.primaryKey) attr.isPrimaryKey = true;
|
|
373
|
+
if (meta.indexed) attr.indexed = true;
|
|
374
|
+
if (meta.assignCreatedTime) attr.assignCreatedTime = true;
|
|
375
|
+
if (meta.assignUpdatedTime) attr.assignUpdatedTime = true;
|
|
376
|
+
if (meta.enum) attr.enum = meta.enum; // enum -> String column, literal set retained for downstream surfaces
|
|
377
|
+
if (!meta.nullable && !meta.primaryKey && !meta.assignCreatedTime && !meta.assignUpdatedTime) attr.nullable = false; // required, like GraphQL `!`
|
|
378
|
+
attributes.push(attr);
|
|
379
|
+
properties[attrName] = attributeToFragment(attr);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const { database, ...tableOptions } = options;
|
|
383
|
+
// `schemaDefined: true` matches @table so the existing-Table re-assert in `table()` fires on
|
|
384
|
+
// every reload (DDL parity: added/removed attributes and index changes are applied).
|
|
385
|
+
return {
|
|
386
|
+
table: name,
|
|
387
|
+
type: name,
|
|
388
|
+
database: database ?? 'data',
|
|
389
|
+
attributes,
|
|
390
|
+
properties,
|
|
391
|
+
schemaDefined: true,
|
|
392
|
+
...tableOptions,
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Declare AND register a table from a TypeScript value. Eager: the returned value is the live,
|
|
398
|
+
* registered table class (the same class `tables.<Name>` holds), typed by the shape — the import
|
|
399
|
+
* IS the typed handle. Defining the same table again re-asserts the schema (add/remove attributes,
|
|
400
|
+
* index changes) through the same evolution path GraphQL reloads take.
|
|
401
|
+
*/
|
|
402
|
+
export function defineTable<S extends Shape>(name: string, shape: S, options: DefineTableOptions = {}): TableHandle<S> {
|
|
403
|
+
const typeDef = compileTypeDef(name, shape, options);
|
|
404
|
+
const tableClass = table(typeDef);
|
|
405
|
+
typeDef.tableClass = tableClass;
|
|
406
|
+
return tableClass as TableHandle<S>;
|
|
407
|
+
}
|
|
@@ -57,6 +57,8 @@ export interface AttributeLike {
|
|
|
57
57
|
assignUpdatedTime?: boolean;
|
|
58
58
|
nullable?: boolean;
|
|
59
59
|
elements?: AttributeLike;
|
|
60
|
+
/** Sub-attributes of a nested object field (the same array form `Table.validate` iterates). */
|
|
61
|
+
properties?: AttributeLike[];
|
|
60
62
|
}
|
|
61
63
|
|
|
62
64
|
/**
|
|
@@ -68,10 +70,16 @@ export interface AttributeLike {
|
|
|
68
70
|
*/
|
|
69
71
|
export function attributeToFragment(attr: AttributeLike): JsonSchemaFragment {
|
|
70
72
|
const fragment: JsonSchemaFragment = {};
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
|
|
73
|
+
// A nested object field carries its sub-attributes on `.properties` (the array form the parser
|
|
74
|
+
// emits and `Table.validate` iterates) — recurse into them rather than emitting the bare GraphQL
|
|
75
|
+
// type name as a JSON-Schema `type`. GraphQL parser emits list types as `prop.type === 'array'`
|
|
76
|
+
// with `.elements`; map that to JSON Schema's items shape; otherwise fall through to the primitive
|
|
77
|
+
// mapping.
|
|
78
|
+
if (attr.properties) {
|
|
79
|
+
fragment.type = 'object';
|
|
80
|
+
fragment.properties = {};
|
|
81
|
+
for (const sub of attr.properties) fragment.properties[sub.name] = attributeToFragment(sub);
|
|
82
|
+
} else if (attr.type === 'array' && attr.elements) {
|
|
75
83
|
fragment.type = 'array';
|
|
76
84
|
fragment.items = attributeToFragment(attr.elements);
|
|
77
85
|
} else {
|
package/resources/openApi.ts
CHANGED
|
@@ -334,9 +334,21 @@ export function generateJsonApi(resources: Resources, serverHttpURL: string) {
|
|
|
334
334
|
|
|
335
335
|
const tableDoc: string | undefined = entry.Resource.description;
|
|
336
336
|
const withDoc = (sentence: string) => (tableDoc ? `${tableDoc} ${sentence}` : sentence);
|
|
337
|
-
const
|
|
338
|
-
// custom (non-table) resources have no generated schema component, so request bodies
|
|
337
|
+
const genericResponseSchema = { type: 'object' };
|
|
338
|
+
// custom (non-table) resources have no generated schema component, so request bodies default to
|
|
339
|
+
// loosely typed — UNLESS a request contract declares the query/body/response,
|
|
340
|
+
// in which case each verb is driven off the shared JsonSchemaFragment below.
|
|
339
341
|
const genericRequestBody = { content: { 'application/json': { schema: { type: 'object' } } } };
|
|
342
|
+
const inputSchemas = entry.Resource.inputSchemas ?? {};
|
|
343
|
+
const outputSchemas = entry.Resource.outputSchemas ?? {};
|
|
344
|
+
// query params declared on the verb's contract, appended to the path params
|
|
345
|
+
const paramsFor = (verb: string) => [...pathParams, ...queryParametersFromFragment(inputSchemas[verb]?.query)];
|
|
346
|
+
const bodyFor = (verb: string) =>
|
|
347
|
+
inputSchemas[verb]?.body
|
|
348
|
+
? { content: { 'application/json': { schema: fragmentToOpenApiSchema(inputSchemas[verb].body) } } }
|
|
349
|
+
: genericRequestBody;
|
|
350
|
+
const responseFor = (verb: string) =>
|
|
351
|
+
outputSchemas[verb] ? fragmentToOpenApiSchema(outputSchemas[verb]) : genericResponseSchema;
|
|
340
352
|
|
|
341
353
|
const hasPost = prototype.post !== Resource.prototype.post || prototype.update;
|
|
342
354
|
const hasPut = typeof prototype.put === 'function';
|
|
@@ -353,43 +365,48 @@ export function generateJsonApi(resources: Resources, serverHttpURL: string) {
|
|
|
353
365
|
);
|
|
354
366
|
if (hasGet) {
|
|
355
367
|
api.paths[url].get = new Get(
|
|
356
|
-
|
|
368
|
+
paramsFor('get'),
|
|
357
369
|
security,
|
|
358
|
-
{ '200': new Response200(
|
|
370
|
+
{ '200': new Response200(responseFor('get')) },
|
|
359
371
|
withDoc('handle a GET request for this route')
|
|
360
372
|
);
|
|
361
373
|
}
|
|
362
374
|
if (hasPost) {
|
|
363
375
|
api.paths[url].post = {
|
|
364
376
|
description: withDoc('handle a POST request for this route'),
|
|
365
|
-
parameters:
|
|
377
|
+
parameters: paramsFor('post'),
|
|
366
378
|
security,
|
|
367
|
-
requestBody:
|
|
368
|
-
responses: { '200': new Response200(
|
|
379
|
+
requestBody: bodyFor('post'),
|
|
380
|
+
responses: { '200': new Response200(responseFor('post')) },
|
|
369
381
|
};
|
|
370
382
|
}
|
|
371
383
|
if (hasPut) {
|
|
372
384
|
api.paths[url].put = {
|
|
373
385
|
description: withDoc('handle a PUT request for this route'),
|
|
374
|
-
parameters:
|
|
386
|
+
parameters: paramsFor('put'),
|
|
375
387
|
security,
|
|
376
|
-
requestBody:
|
|
377
|
-
responses: { '200': new Response200(
|
|
388
|
+
requestBody: bodyFor('put'),
|
|
389
|
+
responses: { '200': new Response200(responseFor('put')) },
|
|
378
390
|
};
|
|
379
391
|
}
|
|
380
392
|
if (hasPatch) {
|
|
381
393
|
api.paths[url].patch = {
|
|
382
394
|
description: withDoc('handle a PATCH request for this route'),
|
|
383
|
-
parameters:
|
|
395
|
+
parameters: paramsFor('patch'),
|
|
384
396
|
security,
|
|
385
|
-
requestBody:
|
|
386
|
-
responses: { '200': new Response200(
|
|
397
|
+
requestBody: bodyFor('patch'),
|
|
398
|
+
responses: { '200': new Response200(responseFor('patch')) },
|
|
387
399
|
};
|
|
388
400
|
}
|
|
389
401
|
if (hasDelete) {
|
|
390
|
-
api.paths[url].delete = new Delete(
|
|
391
|
-
'
|
|
392
|
-
|
|
402
|
+
api.paths[url].delete = new Delete(
|
|
403
|
+
paramsFor('delete'),
|
|
404
|
+
security,
|
|
405
|
+
withDoc('handle a DELETE request for this route'),
|
|
406
|
+
{
|
|
407
|
+
'204': new Response204(),
|
|
408
|
+
}
|
|
409
|
+
);
|
|
393
410
|
}
|
|
394
411
|
}
|
|
395
412
|
|
|
@@ -523,3 +540,38 @@ function Parameter(name, i, type) {
|
|
|
523
540
|
this.in = i;
|
|
524
541
|
this.schema = type;
|
|
525
542
|
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Project a shared `JsonSchemaFragment` (from a request contract's inputSchemas/outputSchemas) into an
|
|
546
|
+
* OpenAPI schema object. The fragment vocabulary already mirrors JSON Schema, so this mostly copies the
|
|
547
|
+
* standard keys recursively and drops Harper-only metadata (primaryKey, assignCreatedTime, hidden).
|
|
548
|
+
*/
|
|
549
|
+
function fragmentToOpenApiSchema(fragment: any): any {
|
|
550
|
+
if (!fragment || typeof fragment !== 'object') return { type: 'object' };
|
|
551
|
+
const schema: any = {};
|
|
552
|
+
if (fragment.type != null) schema.type = fragment.type;
|
|
553
|
+
if (fragment.format) schema.format = fragment.format;
|
|
554
|
+
if (fragment.description) schema.description = fragment.description;
|
|
555
|
+
if (fragment.enum) schema.enum = fragment.enum;
|
|
556
|
+
if (fragment.nullable) schema.nullable = true;
|
|
557
|
+
if (fragment.const !== undefined) schema.const = fragment.const;
|
|
558
|
+
if (fragment.items) schema.items = fragmentToOpenApiSchema(fragment.items);
|
|
559
|
+
if (fragment.properties) {
|
|
560
|
+
schema.properties = {};
|
|
561
|
+
for (const [key, sub] of Object.entries(fragment.properties)) schema.properties[key] = fragmentToOpenApiSchema(sub);
|
|
562
|
+
if (fragment.required) schema.required = fragment.required;
|
|
563
|
+
if (fragment.additionalProperties !== undefined) schema.additionalProperties = fragment.additionalProperties;
|
|
564
|
+
}
|
|
565
|
+
return schema;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/** Build OpenAPI query `Parameter`s from a contract verb's query fragment (`{ type:'object', properties }`). */
|
|
569
|
+
function queryParametersFromFragment(queryFragment: any): any[] {
|
|
570
|
+
if (!queryFragment?.properties) return [];
|
|
571
|
+
const required = new Set(queryFragment.required ?? []);
|
|
572
|
+
return Object.entries(queryFragment.properties).map(([name, sub]) => {
|
|
573
|
+
const parameter = new Parameter(name, 'query', fragmentToOpenApiSchema(sub));
|
|
574
|
+
if (required.has(name)) parameter.required = true;
|
|
575
|
+
return parameter;
|
|
576
|
+
});
|
|
577
|
+
}
|
package/resources/search.ts
CHANGED
|
@@ -56,7 +56,7 @@ export function executeConditions(
|
|
|
56
56
|
recordAccess?
|
|
57
57
|
) {
|
|
58
58
|
const firstSearch = conditions[0];
|
|
59
|
-
// Record-level guards (a caller-supplied vectorFilter +
|
|
59
|
+
// Record-level guards (a caller-supplied vectorFilter + a record-scoped allowRead) apply to every record
|
|
60
60
|
// the query returns, independent of which condition leads (#1241). `recordAccess` is supplied only on
|
|
61
61
|
// the top-level executeConditions call (Table.search) and deliberately NOT threaded into the recursive
|
|
62
62
|
// calls below, so the guards run exactly once — on the final result set — rather than redundantly at
|
|
@@ -161,9 +161,10 @@ export function executeConditions(
|
|
|
161
161
|
}
|
|
162
162
|
|
|
163
163
|
/**
|
|
164
|
-
* Build the record-level guards that apply to a query independent of its conditions (#1241):
|
|
164
|
+
* Build the record-level guards that apply to a query independent of its conditions (#1241/#1422):
|
|
165
165
|
* - `vectorFilter`: a caller-supplied `(record) => boolean` predicate (JS-API only).
|
|
166
|
-
* - `
|
|
166
|
+
* - `recordGuard`: the record-scoped allowRead check, pre-bound (fail-closed) by Table.search —
|
|
167
|
+
* invokes the resource class's overridden allowRead with `this` = each record.
|
|
167
168
|
* Both arrive already resolved on `recordAccess` (assembled once in Table.search). Returns an array of
|
|
168
169
|
* `(record) => boolean` predicates, or undefined when neither is defined (the common case — zero
|
|
169
170
|
* overhead). Predicates must be synchronous and side-effect free; records passed in are frozen.
|
|
@@ -173,11 +174,7 @@ function buildRecordGuards(recordAccess): ((record: any) => boolean)[] | undefin
|
|
|
173
174
|
const guards: ((record: any) => boolean)[] = [];
|
|
174
175
|
const vectorFilter = recordAccess.vectorFilter;
|
|
175
176
|
if (typeof vectorFilter === 'function') guards.push((record) => vectorFilter(record));
|
|
176
|
-
|
|
177
|
-
if (typeof allowReadRecord === 'function') {
|
|
178
|
-
const user = recordAccess.user;
|
|
179
|
-
guards.push((record) => allowReadRecord(user, record));
|
|
180
|
-
}
|
|
177
|
+
if (recordAccess.recordGuard) guards.push(recordAccess.recordGuard);
|
|
181
178
|
return guards.length > 0 ? guards : undefined;
|
|
182
179
|
}
|
|
183
180
|
|
|
@@ -315,11 +315,13 @@ async function processFieldNode(
|
|
|
315
315
|
const query = {
|
|
316
316
|
select: buildSelectQuery(fieldNode.selectionSet, fragments),
|
|
317
317
|
conditions: buildConditionsQuery(fieldNode.arguments, resolvedVariables),
|
|
318
|
+
// Request authorization directly on the query target (checkPermission) rather than via the
|
|
319
|
+
// context-level `authorize` alias — same wrapper handling, and it keeps GraphQL consistent
|
|
320
|
+
// with the REST query path, including row-level (record-scoped allowRead) enforcement.
|
|
321
|
+
checkPermission: true,
|
|
318
322
|
};
|
|
319
323
|
|
|
320
324
|
const results = [];
|
|
321
|
-
// @ts-expect-error: `authorize` is a custom property on the request object.
|
|
322
|
-
request.authorize = true;
|
|
323
325
|
for await (const result of resource.search(query, request)) {
|
|
324
326
|
results.push(result);
|
|
325
327
|
}
|