@phis/contracts 0.2.0 → 0.3.1

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/dist/queries.d.ts CHANGED
@@ -12,13 +12,36 @@
12
12
  * and a join between two Add-ons' tables is already forbidden. A query that needs one is either cut
13
13
  * wrong or wants a Core service.
14
14
  */
15
- /** What a declared parameter holds. Narrower than the column vocabulary: these travel as values. */
16
- export type PhiServerAddonQueryParameterType = "integer" | "bigInteger" | "text" | "boolean" | "instant" | "uuid";
17
- export type PhiServerAddonQueryParameter = {
15
+ /**
16
+ * What a declared parameter holds. Narrower than the column vocabulary: these travel as values.
17
+ *
18
+ * `json` is the one that is not a scalar, and it is deliberately the one that cannot be compared. It
19
+ * may be assigned to a `json` column and used nowhere else -- not in `compare`, not in `contains`, not
20
+ * in `in`, not in `whenPresent`. A document one could filter on would need paths, operators and an
21
+ * index vocabulary to go with them, and that is a second query language growing inside the one this
22
+ * file exists to keep small. What it is for is the other half: a column declared `json` had no way to
23
+ * be written at all, so an Add-on could store a document only by never storing one.
24
+ */
25
+ export type PhisAddonQueryParameterType = "integer" | "bigInteger" | "text" | "boolean" | "instant" | "uuid" | "json";
26
+ export type PhisAddonQueryParameter = {
18
27
  name: string;
19
- type: PhiServerAddonQueryParameterType;
28
+ type: PhisAddonQueryParameterType;
20
29
  /** Whether the caller may pass null. Absent means it may not. */
21
30
  nullable?: boolean;
31
+ /**
32
+ * Whether this parameter carries many values rather than one.
33
+ *
34
+ * Only usable by `in`, and the reason `in` is worth having at all. Its other form takes values fixed
35
+ * in the descriptor, which answers "one of these three states" and nothing a caller brings -- and the
36
+ * commonest need is exactly that: the rows belonging to the page that was just read, whose keys are
37
+ * known only at request time.
38
+ *
39
+ * It stays one binding. Core writes `= ANY($n::TYPE[])` rather than expanding the list into the
40
+ * statement, so the query has one shape however many values arrive; a statement whose text grew with
41
+ * its arguments would be assembled at request time, which is the thing this file exists to prevent.
42
+ * Core caps the length, because a list is an unbounded read wearing a parameter.
43
+ */
44
+ list?: boolean;
22
45
  };
23
46
  /**
24
47
  * Where a value in a query comes from.
@@ -28,7 +51,7 @@ export type PhiServerAddonQueryParameter = {
28
51
  * bound actor, so the query cannot be aimed at somebody else -- the same reason the capability closes
29
52
  * over the Site instead of taking it as an argument.
30
53
  */
31
- export type PhiServerAddonQueryValue = {
54
+ export type PhisAddonQueryValue = {
32
55
  kind: "parameter";
33
56
  name: string;
34
57
  } | {
@@ -41,59 +64,160 @@ export type PhiServerAddonQueryValue = {
41
64
  } | {
42
65
  kind: "now";
43
66
  };
44
- export type PhiServerAddonQueryComparison = "=" | "<>" | "<" | "<=" | ">" | ">=";
67
+ export type PhisAddonQueryComparison = "=" | "<>" | "<" | "<=" | ">" | ">=";
45
68
  /**
46
69
  * A condition, as a tree of a closed set of shapes.
47
70
  *
48
- * No expressions, no functions, no pattern matching. `LIKE` is absent for the reason regular
71
+ * No expressions, no functions, no patterns an Add-on writes. `LIKE` is absent for the reason regular
49
72
  * expressions are absent from the schema descriptor: its escaping and collation behaviour differ
50
- * between database systems, and taking it would buy back the dependency this exists to remove. Text
51
- * search is a Core service, not a query the Add-on writes.
73
+ * between database systems, and taking it would buy back the dependency this exists to remove. What
74
+ * replaces it is `contains`, an operator: the Add-on names a column and a value, and Core decides how
75
+ * that is spelled and escaped.
52
76
  */
53
- export type PhiServerAddonQueryCondition = {
77
+ export type PhisAddonQueryCondition = {
54
78
  kind: "compare";
55
79
  column: string;
56
- operator: PhiServerAddonQueryComparison;
57
- value: PhiServerAddonQueryValue;
80
+ operator: PhisAddonQueryComparison;
81
+ value: PhisAddonQueryValue;
58
82
  } | {
59
83
  kind: "isNull";
60
84
  column: string;
61
85
  negated?: boolean;
62
- } | {
86
+ }
87
+ /**
88
+ * Whether a text column contains this fragment, case-insensitively.
89
+ *
90
+ * The one search the vocabulary offers, and it is an operator rather than a pattern: the Add-on says
91
+ * what it wants and Core writes the statement, so there is no `LIKE` syntax in a manifest and no
92
+ * escaping for an author to get wrong. A `%` or a `_` in the value is a character to look for, not a
93
+ * wildcard.
94
+ *
95
+ * It wants a `text` index on the column. Without one it still works and reads the whole table to do
96
+ * it, which `phis addon check` reports at install rather than leaving to be discovered in production.
97
+ */
98
+ | {
99
+ kind: "contains";
100
+ column: string;
101
+ value: PhisAddonQueryValue;
102
+ }
103
+ /** One of a set fixed in the descriptor: a state, a kind, a handful of names the Add-on knows. */
104
+ | {
105
+ kind: "in";
106
+ column: string;
107
+ values: PhisAddonQueryValue[];
108
+ parameter?: never;
109
+ }
110
+ /**
111
+ * One of a set the caller brings, named by a `list` parameter.
112
+ *
113
+ * What makes a second query about the same rows possible: read a page, then ask something else about
114
+ * exactly those keys. Without it the second question can only be asked one row at a time or about the
115
+ * whole table, and neither is a page.
116
+ */
117
+ | {
63
118
  kind: "in";
64
119
  column: string;
65
- values: PhiServerAddonQueryValue[];
120
+ parameter: string;
121
+ values?: never;
66
122
  } | {
67
123
  kind: "all";
68
- conditions: PhiServerAddonQueryCondition[];
124
+ conditions: PhisAddonQueryCondition[];
69
125
  } | {
70
126
  kind: "any";
71
- conditions: PhiServerAddonQueryCondition[];
127
+ conditions: PhisAddonQueryCondition[];
128
+ }
129
+ /**
130
+ * A condition that applies only when its parameter was given.
131
+ *
132
+ * A listing has filters the caller may or may not set, and a declared query has one fixed `where`.
133
+ * Without this, every combination is its own query -- three optional filters are eight of them, four
134
+ * are sixteen, each declared, validated and kept in step by hand. The parameter must be nullable, and
135
+ * null means the condition is not there rather than that it matched nothing.
136
+ */
137
+ | {
138
+ kind: "whenPresent";
139
+ parameter: string;
140
+ condition: PhisAddonQueryCondition;
72
141
  };
73
- export type PhiServerAddonQueryOrdering = {
142
+ export type PhisAddonQueryOrdering = {
74
143
  column: string;
75
144
  direction: "asc" | "desc";
76
145
  };
77
- export type PhiServerAddonQueryAssignment = {
146
+ export type PhisAddonQueryAssignment = {
78
147
  column: string;
79
- value: PhiServerAddonQueryValue;
148
+ value: PhisAddonQueryValue;
80
149
  };
81
150
  /**
82
151
  * Reading rows of one of this Add-on's tables.
83
152
  *
84
- * `limit` is required and bounded. An unbounded read is how an Add-on takes an instance down with a
85
- * table that grew, and "the caller will pass a sensible one" is not a bound.
153
+ * Every select is paged, whether or not it was written as a listing. There is no unpaged form to reach
154
+ * for: an unbounded read is how an Add-on takes an instance down with a table that grew, and "the
155
+ * caller will pass a sensible one" is not a bound.
156
+ *
157
+ * `orderBy` is completed by Core with the primary key, so the order is total. Without that a cursor is
158
+ * quietly wrong: two rows sharing a sort value may come back in either order, and a row then appears on
159
+ * two pages while another appears on none. It is the kind of fault that stays invisible until the table
160
+ * is large, and no Add-on author has to think about it.
161
+ *
162
+ * Every column named in `orderBy` must be declared `NOT NULL`. Where nulls sort is a decision the
163
+ * descriptor cannot express, so an order over a nullable column is underdetermined -- and a keyset
164
+ * comparison against a null value matches nothing at all, which would end the paging early rather than
165
+ * loudly.
166
+ */
167
+ /**
168
+ * A role the acting user must hold for this query to run at all.
169
+ *
170
+ * The one thing Core does with an Add-on's role vocabulary, and it is deliberately the smallest thing
171
+ * that is useful: it compares a name against what is stored. Core does not learn that `curator` may
172
+ * moderate -- that meaning stays here, as the pairing of a role with a query, which is the Add-on's own
173
+ * statement about its own vocabulary.
174
+ *
175
+ * It is what makes a table nobody owns writable. Without an owner and without a group, Core has no way
176
+ * to judge a row and answers no to every update and delete -- correct for a row that should belong to
177
+ * somebody, wrong for a catalogue, a rate table, or a moderation note, which belong to the Add-on. A
178
+ * declared role is the missing authority, and being declared is what makes it unforgettable: the check
179
+ * is in the descriptor rather than at the top of a handler, where it is one refactor from gone.
180
+ *
181
+ * On a table that is owned or grouped it narrows rather than replaces: both the role and the ladder
182
+ * have to be satisfied.
86
183
  */
87
- export type PhiServerAddonSelectQueryDescriptor = {
184
+ type PhisAddonRoleGuarded = {
185
+ /** Names a role this Add-on's manifest declares. Absent means the query needs none. */
186
+ requiresRole?: string;
187
+ /**
188
+ * A role that stands in for the row's own authority, where the ladder alone would say no.
189
+ *
190
+ * The other direction, and the one moderation needs. `requiresRole` narrows: it asks for the role on
191
+ * top of the ladder, and on an owned table that means role *and* owner -- which nobody moderating a
192
+ * stranger's row can ever be. This one widens: the row is judged first, and a caller the ladder turned
193
+ * down is let through on the role instead. The owner keeps reaching their own row without any role at
194
+ * all.
195
+ *
196
+ * It is a bypass, and it is named so that writing it down feels like declaring one. An Add-on that
197
+ * hands a role the run of a table is making a real statement about that role, and it should be visible
198
+ * in the descriptor rather than inferred from the absence of something.
199
+ *
200
+ * Only on `update` and `delete`, which are the only places a row is judged, and only on a table that
201
+ * has authority to bypass. Not combined with `requiresRole`: a query needing both a narrowing and a
202
+ * widening role is two queries wearing one name.
203
+ */
204
+ alsoAllowedByRole?: string;
205
+ };
206
+ export type PhisAddonSelectQueryDescriptor = PhisAddonRoleGuarded & {
88
207
  kind: "select";
89
208
  name: string;
90
209
  table: string;
91
210
  /** Absent means every declared column. Core's scope columns are never among them. */
92
211
  columns?: string[];
93
- where?: PhiServerAddonQueryCondition;
94
- orderBy?: PhiServerAddonQueryOrdering[];
212
+ where?: PhisAddonQueryCondition;
213
+ orderBy?: PhisAddonQueryOrdering[];
214
+ /**
215
+ * The largest page this query will ever serve, whatever the caller asks for.
216
+ *
217
+ * The Add-on's own ceiling, not the page size: a call may ask for less, and Core takes the smallest of
218
+ * the request, this, and `PHIS_ADDON_QUERY_MAX_LIMIT`.
219
+ */
95
220
  limit: number;
96
- offsetParameter?: string;
97
221
  };
98
222
  /**
99
223
  * Adding a row.
@@ -103,11 +227,11 @@ export type PhiServerAddonSelectQueryDescriptor = {
103
227
  * context; the group comes from a declared parameter because a row can belong to any group the actor is
104
228
  * in, and Core refuses one they are not.
105
229
  */
106
- export type PhiServerAddonInsertQueryDescriptor = {
230
+ export type PhisAddonInsertQueryDescriptor = PhisAddonRoleGuarded & {
107
231
  kind: "insert";
108
232
  name: string;
109
233
  table: string;
110
- values: PhiServerAddonQueryAssignment[];
234
+ values: PhisAddonQueryAssignment[];
111
235
  /** Names the parameter carrying the group id, and is required for a group-scoped table. */
112
236
  groupParameter?: string;
113
237
  };
@@ -119,29 +243,112 @@ export type PhiServerAddonInsertQueryDescriptor = {
119
243
  * unskippable rather than the discipline. An Add-on that needs to rewrite many rows at once is doing
120
244
  * something Core should be doing for it.
121
245
  */
122
- export type PhiServerAddonUpdateQueryDescriptor = {
246
+ export type PhisAddonUpdateQueryDescriptor = PhisAddonRoleGuarded & {
123
247
  kind: "update";
124
248
  name: string;
125
249
  table: string;
126
- set: PhiServerAddonQueryAssignment[];
250
+ set: PhisAddonQueryAssignment[];
127
251
  /** Names the parameter carrying the row's primary key value. */
128
252
  idParameter: string;
129
253
  };
130
- export type PhiServerAddonDeleteQueryDescriptor = {
254
+ export type PhisAddonDeleteQueryDescriptor = PhisAddonRoleGuarded & {
131
255
  kind: "delete";
132
256
  name: string;
133
257
  table: string;
134
258
  idParameter: string;
135
259
  };
136
- export type PhiServerAddonQueryDescriptor = PhiServerAddonSelectQueryDescriptor | PhiServerAddonInsertQueryDescriptor | PhiServerAddonUpdateQueryDescriptor | PhiServerAddonDeleteQueryDescriptor;
260
+ /**
261
+ * `count` without a column counts rows; every other function names one.
262
+ *
263
+ * `sum` and `avg` want an integer column, and validation says so rather than letting PostgreSQL raise it
264
+ * at the first call: a manifest that averages a text column is wrong when it is written, not when it is
265
+ * run. `avg` comes back as a string, because it is `numeric` and a JavaScript number is not.
266
+ */
267
+ export type PhisAddonAggregateFunction = "count" | "sum" | "avg" | "min" | "max";
268
+ export type PhisAddonQueryAggregate = {
269
+ function: PhisAddonAggregateFunction;
270
+ column?: string;
271
+ /** What the value is called in the answer. Must not collide with a grouping column. */
272
+ as: string;
273
+ };
274
+ /**
275
+ * Counting and summarising rows instead of returning them.
276
+ *
277
+ * The question a select cannot answer: "how many" and "what on average", per something. Without it an
278
+ * Add-on reads every row to count them, which is the unbounded read paging exists to prevent, or keeps a
279
+ * running total in a column of its own -- a number Core cannot hold true and that drifts the first time
280
+ * a write is lost.
281
+ *
282
+ * It reads no row a select could not have read. The ladder is not consulted for either: what a query may
283
+ * see is what the query says, `actor` and all, and an aggregate over rows the caller may not read one by
284
+ * one is an aggregate the Add-on wrote that way deliberately. What changes here is arithmetic, not
285
+ * reach.
286
+ *
287
+ * `groupBy` decides the shape of the answer. Without it there is exactly one row and no paging. With it
288
+ * there is one row per group, paged like a select -- and the grouping columns are the ordering, which is
289
+ * total by construction, since `GROUP BY` yields each combination once. That is also why they must be
290
+ * `NOT NULL`, and why there is no ordering by an aggregated value: ranking by an average would page on a
291
+ * key that is neither unique nor known before the group is computed.
292
+ */
293
+ export type PhisAddonAggregateQueryDescriptor = PhisAddonRoleGuarded & {
294
+ kind: "aggregate";
295
+ name: string;
296
+ table: string;
297
+ aggregates: PhisAddonQueryAggregate[];
298
+ where?: PhisAddonQueryCondition;
299
+ groupBy?: string[];
300
+ /** The Add-on's ceiling on groups per page. Required with `groupBy`, meaningless without it. */
301
+ limit?: number;
302
+ };
303
+ export type PhisAddonQueryDescriptor = PhisAddonSelectQueryDescriptor | PhisAddonInsertQueryDescriptor | PhisAddonUpdateQueryDescriptor | PhisAddonDeleteQueryDescriptor | PhisAddonAggregateQueryDescriptor;
137
304
  /** The most rows one declared `select` may return, whatever it asks for. */
138
- export declare const PHI_SERVER_ADDON_QUERY_MAX_LIMIT: 500;
139
- export type PhiServerAddonQueryCatalog = {
140
- parameters: Readonly<Record<string, PhiServerAddonQueryParameter[]>>;
141
- queries: PhiServerAddonQueryDescriptor[];
305
+ export declare const PHIS_ADDON_QUERY_MAX_LIMIT: 500;
306
+ /**
307
+ * The most values one `list` parameter may carry.
308
+ *
309
+ * A page's worth, because that is what it is for: the keys just read, asked about again. Larger, and it
310
+ * is the unbounded read paging removed, arriving through the one door that still accepts a caller's
311
+ * count. Exceeding it is refused rather than truncated -- a silently shortened list answers about some
312
+ * of the rows and says it answered about all of them.
313
+ */
314
+ export declare const PHIS_ADDON_QUERY_MAX_LIST: 500;
315
+ export type PhisAddonQueryCatalog = {
316
+ parameters: Readonly<Record<string, PhisAddonQueryParameter[]>>;
317
+ queries: PhisAddonQueryDescriptor[];
318
+ };
319
+ /**
320
+ * A document a `json` parameter carries, which is whatever survives a round trip through JSON.
321
+ *
322
+ * Not narrowed further, because the column is not narrowed either: a `json` column takes any document,
323
+ * and a type here that took less would describe a restriction the store does not have.
324
+ */
325
+ export type PhisAddonQueryJson = {
326
+ readonly [key: string]: unknown;
327
+ } | readonly unknown[];
328
+ export type PhisAddonQueryArguments = Readonly<Record<string, string | number | boolean | null | readonly (string | number)[] | PhisAddonQueryJson>>;
329
+ export type PhisAddonQueryRow = Readonly<Record<string, unknown>>;
330
+ /**
331
+ * One page of a select, and whether there is another.
332
+ *
333
+ * `cursor` present means there is more; absent means that was everything. It is the one promise a
334
+ * consumer can neither detect nor repair on its own -- a truncated answer is indistinguishable from a
335
+ * complete one unless the answer says so -- and Core keeps it by reading one row more than it returns,
336
+ * which costs a row and no count.
337
+ *
338
+ * The cursor is opaque. It encodes the ordering it was made for and is refused by a query with a
339
+ * different one, because paging on somebody else's order silently skips and repeats rows.
340
+ */
341
+ export type PhisAddonQueryPage = {
342
+ rows: PhisAddonQueryRow[];
343
+ cursor: string | null;
344
+ };
345
+ /** What a caller asks for a page, beside the query's own parameters. */
346
+ export type PhisAddonQueryPageRequest = {
347
+ /** A wish. Core takes the smallest of this, the descriptor's `limit`, and the hard maximum. */
348
+ limit?: number;
349
+ /** From a previous page. Absent starts at the beginning. */
350
+ cursor?: string | null;
142
351
  };
143
- export type PhiServerAddonQueryArguments = Readonly<Record<string, string | number | boolean | null>>;
144
- export type PhiServerAddonQueryRow = Readonly<Record<string, unknown>>;
145
352
  /**
146
353
  * Running the declared queries: `@phis/server/data:v1`.
147
354
  *
@@ -149,23 +356,48 @@ export type PhiServerAddonQueryRow = Readonly<Record<string, unknown>>;
149
356
  * condition, because an Add-on that could pass one of those at request time would be holding a
150
357
  * database handle with extra steps.
151
358
  *
152
- * A mutation answers how many rows it touched. Zero from an `update` or `delete` means the row was not
153
- * there or was not the caller's to change, and the two are deliberately the same answer: telling them
154
- * apart would report the existence of a row the caller may not have.
359
+ * Every mutation answers with the row it wrote and `null` where it wrote none. `null` means the row was
360
+ * not there or was not the caller's to change, and the two are deliberately the same answer: telling
361
+ * them apart would report the existence of a row the caller may not have. The row itself withholds
362
+ * nothing further, because it comes back only when the write it describes actually happened.
363
+ *
364
+ * A select answers with a page. There is no method that returns everything, because the caller who
365
+ * wanted everything is the one who brings the instance down.
155
366
  */
156
- export type PhiServerDataCapabilityV1 = {
157
- select(name: string, args?: PhiServerAddonQueryArguments): Promise<PhiServerAddonQueryRow[]>;
367
+ export type PhisDataCapabilityV1 = {
368
+ select(name: string, args?: PhisAddonQueryArguments, page?: PhisAddonQueryPageRequest): Promise<PhisAddonQueryPage>;
369
+ /**
370
+ * Counting and summarising, as a page for the same reason a select is one.
371
+ *
372
+ * A grouped aggregate has as many rows as there are groups, which is a number the Add-on does not know
373
+ * either. Ungrouped, the page holds one row and carries no cursor.
374
+ */
375
+ aggregate(name: string, args?: PhisAddonQueryArguments, page?: PhisAddonQueryPageRequest): Promise<PhisAddonQueryPage>;
158
376
  /** Returns the inserted row, with the scope columns Core filled in. */
159
- insert(name: string, args?: PhiServerAddonQueryArguments): Promise<PhiServerAddonQueryRow | null>;
160
- update(name: string, args?: PhiServerAddonQueryArguments): Promise<number>;
161
- delete(name: string, args?: PhiServerAddonQueryArguments): Promise<number>;
377
+ insert(name: string, args?: PhisAddonQueryArguments): Promise<PhisAddonQueryRow | null>;
378
+ /**
379
+ * Returns the row as it stands after the write, or `null` when nothing was written.
380
+ *
381
+ * The row rather than a count, because what was sent and what is now stored are not the same thing
382
+ * wherever a default, a constraint, or a column the query did not name had a say.
383
+ */
384
+ update(name: string, args?: PhisAddonQueryArguments): Promise<PhisAddonQueryRow | null>;
385
+ /**
386
+ * Returns the row that was removed, or `null` when nothing was.
387
+ *
388
+ * The row is the only thing a delete can give that a count cannot, and it is needed: a row naming an
389
+ * object in the Add-on's store is the last record of that object, and once the statement has run there
390
+ * is nothing left to ask. The bytes would stay, counted against the quota, belonging to nobody.
391
+ */
392
+ delete(name: string, args?: PhisAddonQueryArguments): Promise<PhisAddonQueryRow | null>;
162
393
  /**
163
394
  * One unit of work. Everything inside commits together or not at all.
164
395
  *
165
- * The handle offers the same four operations and nothing else -- no commit, no rollback, no
396
+ * The handle offers the same five operations and nothing else -- no commit, no rollback, no
166
397
  * savepoint. Returning ends the transaction; throwing rolls it back. An Add-on cannot leave one
167
398
  * open, because it never holds the thing that would stay open.
168
399
  */
169
- transaction<T>(body: (tx: PhiServerDataTransactionV1) => Promise<T>): Promise<T>;
400
+ transaction<T>(body: (tx: PhisDataTransactionV1) => Promise<T>): Promise<T>;
170
401
  };
171
- export type PhiServerDataTransactionV1 = Omit<PhiServerDataCapabilityV1, "transaction">;
402
+ export type PhisDataTransactionV1 = Omit<PhisDataCapabilityV1, "transaction">;
403
+ export {};
package/dist/queries.js CHANGED
@@ -13,4 +13,13 @@
13
13
  * wrong or wants a Core service.
14
14
  */
15
15
  /** The most rows one declared `select` may return, whatever it asks for. */
16
- export const PHI_SERVER_ADDON_QUERY_MAX_LIMIT = 500;
16
+ export const PHIS_ADDON_QUERY_MAX_LIMIT = 500;
17
+ /**
18
+ * The most values one `list` parameter may carry.
19
+ *
20
+ * A page's worth, because that is what it is for: the keys just read, asked about again. Larger, and it
21
+ * is the unbounded read paging removed, arriving through the one door that still accepts a caller's
22
+ * count. Exceeding it is refused rather than truncated -- a silently shortened list answers about some
23
+ * of the rows and says it answered about all of them.
24
+ */
25
+ export const PHIS_ADDON_QUERY_MAX_LIST = 500;