@thomasfosterau/pulpit 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1271 -1
  3. package/dist/blobs.d.ts +249 -0
  4. package/dist/blobs.d.ts.map +1 -0
  5. package/dist/blobs.js +193 -0
  6. package/dist/blobs.js.map +1 -0
  7. package/dist/budgets.d.ts +255 -0
  8. package/dist/budgets.d.ts.map +1 -0
  9. package/dist/budgets.js +211 -0
  10. package/dist/budgets.js.map +1 -0
  11. package/dist/cache.d.ts +154 -0
  12. package/dist/cache.d.ts.map +1 -0
  13. package/dist/cache.js +118 -0
  14. package/dist/cache.js.map +1 -0
  15. package/dist/collections.d.ts +242 -0
  16. package/dist/collections.d.ts.map +1 -0
  17. package/dist/collections.js +215 -0
  18. package/dist/collections.js.map +1 -0
  19. package/dist/comments.d.ts +212 -0
  20. package/dist/comments.d.ts.map +1 -0
  21. package/dist/comments.js +192 -0
  22. package/dist/comments.js.map +1 -0
  23. package/dist/content.d.ts +231 -0
  24. package/dist/content.d.ts.map +1 -0
  25. package/dist/content.js +204 -0
  26. package/dist/content.js.map +1 -0
  27. package/dist/documents.d.ts +184 -0
  28. package/dist/documents.d.ts.map +1 -0
  29. package/dist/documents.js +190 -0
  30. package/dist/documents.js.map +1 -0
  31. package/dist/edges.d.ts +645 -0
  32. package/dist/edges.d.ts.map +1 -0
  33. package/dist/edges.js +558 -0
  34. package/dist/edges.js.map +1 -0
  35. package/dist/filter.d.ts +461 -0
  36. package/dist/filter.d.ts.map +1 -0
  37. package/dist/filter.js +387 -0
  38. package/dist/filter.js.map +1 -0
  39. package/dist/index.d.ts +133 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +133 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/internal/filter-columns.d.ts +8 -0
  44. package/dist/internal/filter-columns.d.ts.map +1 -0
  45. package/dist/internal/filter-columns.js +43 -0
  46. package/dist/internal/filter-columns.js.map +1 -0
  47. package/dist/internal/list-order.d.ts +26 -0
  48. package/dist/internal/list-order.d.ts.map +1 -0
  49. package/dist/internal/list-order.js +92 -0
  50. package/dist/internal/list-order.js.map +1 -0
  51. package/dist/internal/owned-columns.d.ts +93 -0
  52. package/dist/internal/owned-columns.d.ts.map +1 -0
  53. package/dist/internal/owned-columns.js +98 -0
  54. package/dist/internal/owned-columns.js.map +1 -0
  55. package/dist/internal/plain-rows.d.ts +32 -0
  56. package/dist/internal/plain-rows.d.ts.map +1 -0
  57. package/dist/internal/plain-rows.js +32 -0
  58. package/dist/internal/plain-rows.js.map +1 -0
  59. package/dist/internal/reference-columns.d.ts +80 -0
  60. package/dist/internal/reference-columns.d.ts.map +1 -0
  61. package/dist/internal/reference-columns.js +102 -0
  62. package/dist/internal/reference-columns.js.map +1 -0
  63. package/dist/moderation.d.ts +392 -0
  64. package/dist/moderation.d.ts.map +1 -0
  65. package/dist/moderation.js +375 -0
  66. package/dist/moderation.js.map +1 -0
  67. package/dist/paths.d.ts +204 -0
  68. package/dist/paths.d.ts.map +1 -0
  69. package/dist/paths.js +165 -0
  70. package/dist/paths.js.map +1 -0
  71. package/dist/proposals.d.ts +523 -0
  72. package/dist/proposals.d.ts.map +1 -0
  73. package/dist/proposals.js +505 -0
  74. package/dist/proposals.js.map +1 -0
  75. package/dist/publication.d.ts +148 -0
  76. package/dist/publication.d.ts.map +1 -0
  77. package/dist/publication.js +183 -0
  78. package/dist/publication.js.map +1 -0
  79. package/dist/purge.d.ts +224 -0
  80. package/dist/purge.d.ts.map +1 -0
  81. package/dist/purge.js +151 -0
  82. package/dist/purge.js.map +1 -0
  83. package/dist/reactions.d.ts +246 -0
  84. package/dist/reactions.d.ts.map +1 -0
  85. package/dist/reactions.js +183 -0
  86. package/dist/reactions.js.map +1 -0
  87. package/dist/registry.d.ts +316 -0
  88. package/dist/registry.d.ts.map +1 -0
  89. package/dist/registry.js +305 -0
  90. package/dist/registry.js.map +1 -0
  91. package/dist/search.d.ts +366 -0
  92. package/dist/search.d.ts.map +1 -0
  93. package/dist/search.js +273 -0
  94. package/dist/search.js.map +1 -0
  95. package/dist/social.d.ts +311 -0
  96. package/dist/social.d.ts.map +1 -0
  97. package/dist/social.js +205 -0
  98. package/dist/social.js.map +1 -0
  99. package/dist/subtype.d.ts +1613 -0
  100. package/dist/subtype.d.ts.map +1 -0
  101. package/dist/subtype.js +1300 -0
  102. package/dist/subtype.js.map +1 -0
  103. package/dist/testing.d.ts +675 -0
  104. package/dist/testing.d.ts.map +1 -0
  105. package/dist/testing.js +1022 -0
  106. package/dist/testing.js.map +1 -0
  107. package/dist/version-chain.d.ts +702 -0
  108. package/dist/version-chain.d.ts.map +1 -0
  109. package/dist/version-chain.js +489 -0
  110. package/dist/version-chain.js.map +1 -0
  111. package/dist/working-copy.d.ts +116 -0
  112. package/dist/working-copy.d.ts.map +1 -0
  113. package/dist/working-copy.js +95 -0
  114. package/dist/working-copy.js.map +1 -0
  115. package/package.json +138 -4
  116. package/src/blobs.ts +384 -0
  117. package/src/budgets.ts +338 -0
  118. package/src/cache.ts +218 -0
  119. package/src/collections.ts +427 -0
  120. package/src/comments.ts +392 -0
  121. package/src/content.ts +238 -0
  122. package/src/documents.ts +284 -0
  123. package/src/edges.ts +1185 -0
  124. package/src/filter.ts +523 -0
  125. package/src/index.ts +153 -0
  126. package/src/internal/filter-columns.ts +50 -0
  127. package/src/internal/list-order.ts +117 -0
  128. package/src/internal/owned-columns.ts +140 -0
  129. package/src/internal/plain-rows.ts +54 -0
  130. package/src/internal/reference-columns.ts +156 -0
  131. package/src/moderation.ts +728 -0
  132. package/src/paths.ts +267 -0
  133. package/src/proposals.ts +1014 -0
  134. package/src/publication.ts +225 -0
  135. package/src/purge.ts +313 -0
  136. package/src/reactions.ts +347 -0
  137. package/src/registry.ts +524 -0
  138. package/src/search.ts +540 -0
  139. package/src/social.ts +469 -0
  140. package/src/subtype.ts +3095 -0
  141. package/src/testing.ts +1873 -0
  142. package/src/version-chain.ts +1093 -0
  143. package/src/working-copy.ts +192 -0
package/src/filter.ts ADDED
@@ -0,0 +1,523 @@
1
+ /**
2
+ * **The list-filter vocabulary** — the kernel's read predicates as data.
3
+ *
4
+ * Every visibility, soft-delete and publication rule the kernel owns has two
5
+ * readings: a per-row answer (may this viewer see this row?) and a set-shaped
6
+ * answer a LIST turns into its `WHERE`. In the apps the second is Drizzle SQL
7
+ * built inline (`ownedListFilterE`, `loadListVisibilityFilters`,
8
+ * `publishedFilter`), which ties the rule to one database layer and makes the
9
+ * two readings impossible to compare. Here the set-shaped answer is a small
10
+ * predicate tree, {@link ListFilter}: a Drizzle renderer (the seam
11
+ * effect-drizzle-ext / effect-auth-drizzle fill, see the package README →
12
+ * Seams) turns it into SQL, and
13
+ * {@link evaluate} runs it against an in-memory row — which is what lets the
14
+ * unit suite prove the list filter and the row predicate never disagree.
15
+ *
16
+ * `Following` is whatever a seam hands the `in` node: the follower seam's
17
+ * set of followed accounts, or an edge family's members
18
+ * (`EdgeStore.members`, pulpit#113); a subquery (`SQLWrapper`) for SQL, a
19
+ * set for the in-memory evaluator. The `notIn` node takes the same
20
+ * `Following`, for an exclusion (`EdgeOps.nonMemberFilter`, pulpit#191).
21
+ *
22
+ * Rendering contract a SQL renderer must honour:
23
+ *
24
+ * | node | SQL |
25
+ * | --- | --- |
26
+ * | `always` | no predicate (`undefined` to Drizzle's `.where`) |
27
+ * | `never` | a predicate no row satisfies (the apps: `id = '00000000-…'`) |
28
+ * | `eq` / `ne` | `col = $v` / `col <> $v` — a `NULL` column satisfies neither |
29
+ * | `in` | `col IN (<seam subquery>)` — a subquery, never a materialised array (#1107); a `NULL` column satisfies none |
30
+ * | `notIn` | `(col IS NULL OR col NOT IN (<seam subquery>))` — the complement of `in` over the same subquery (pulpit#191); a `NULL` column satisfies it, and the subquery must never yield a `NULL` (`NOT IN` over a set holding one is `NULL` for every row) |
31
+ * | `gt` / `gte` / `lt` / `lte` | `col > $v` / `col >= $v` / `col < $v` / `col <= $v` — a string compared with `COLLATE "C"` (code-point order), a `Date` as a timestamp, a number numerically (`±Infinity` included); a `NULL` column satisfies none |
32
+ * | `anyOf` | `col = ANY($values)` — a `NULL` column satisfies none; empty is `never`; never a `NULL` element |
33
+ * | `noneOf` | `col <> ALL($values)` — a `NULL` column satisfies none; empty is `always` (`<> ALL` over no values is true); never a `NULL` element (`<> ALL('{1,NULL}')` is `NULL` for every row outside the set) |
34
+ * | `isNull` | `col IS NULL` — the one node a `NULL` column satisfies by itself |
35
+ * | `published` | `(col IS NOT NULL AND col <= now())` — the database's clock, one per query |
36
+ * | `notPublished` | `(col IS NULL OR col > now())` — parenthesised, so it cannot bind to a surrounding `AND` |
37
+ * | `locale` | `col = ANY($locales)` over the view's locale column (pulpit#56) — as `anyOf`; on a view with no locale level, `never` |
38
+ * | `and` / `or` | `AND` / `OR` over the parts |
39
+ *
40
+ * An `anyOf` / `noneOf` node carrying more than {@link MAX_SET_VALUES} values,
41
+ * duplicates counted, is `never`, in the renderer as in {@link evaluate}
42
+ * (`SEC-INPUT-003`); the constructors already fold it so.
43
+ *
44
+ * Outside the contract, where SQL and {@link evaluate} cannot be made to
45
+ * agree, `evaluate` fails closed:
46
+ *
47
+ * - **A column of another type than the comparand** (or than any of a
48
+ * literal set's values) is a caller or schema error. SQL may coerce the
49
+ * parameter (an `integer` column against `'1'`) or raise (against
50
+ * `'abc'`); `evaluate` reads every such row as satisfying no `eq`, `ne`,
51
+ * comparison, `anyOf` or `noneOf`.
52
+ * - **`NaN`** (and an invalid `Date`). PostgreSQL's `float8` / `numeric` hold
53
+ * `NaN` equal to itself and above every number, an order JavaScript's
54
+ * `NaN` does not have. The constructors fold a `NaN` comparand, or a set
55
+ * holding one, to `never`; `evaluate` reads a `NaN` comparand or column as
56
+ * satisfying no `eq`, `ne`, comparison, `anyOf` or `noneOf`; and a renderer
57
+ * must never bind `NaN`, rendering a hand-built node carrying one as `never`.
58
+ * - **Sub-millisecond instants.** A PostgreSQL timestamp keeps microseconds,
59
+ * a JavaScript `Date` milliseconds, so two instants within one millisecond
60
+ * can compare unequal in SQL and equal in `evaluate`.
61
+ *
62
+ * @copyright Thomas Foster
63
+ * @module pulpit/filter
64
+ * @since 0.1.0
65
+ */
66
+
67
+ import * as Array from "effect/Array";
68
+ import * as Match from "effect/Match";
69
+ import * as Order from "effect/Order";
70
+ import * as Predicate from "effect/Predicate";
71
+ import { byCodePoint } from "./internal/list-order.ts";
72
+ import type { ColumnValue } from "./version-chain.ts";
73
+
74
+ /**
75
+ * A value an `eq` / `ne` node compares against. No `null`: `col = NULL` is
76
+ * never true in SQL, so a null comparison would read differently in the
77
+ * renderer and the evaluator.
78
+ *
79
+ * @since 0.1.0
80
+ */
81
+ export type Scalar = string | number | boolean;
82
+
83
+ /**
84
+ * A value a `gt` / `gte` / `lt` / `lte` node compares against. A `string`
85
+ * orders by code point (SQL's `"C"` collation), a `number` numerically and a
86
+ * `Date` by its instant. Kept apart from {@link Scalar}, which `eq` / `ne`
87
+ * test by identity and so cannot hold a `Date`; a `boolean` has no order a
88
+ * filter needs.
89
+ *
90
+ * @since 0.1.0
91
+ */
92
+ export type Comparable = string | number | Date;
93
+
94
+ /**
95
+ * The most values an `anyOf` / `noneOf` node carries (`SEC-INPUT-003`): a
96
+ * literal set past it is `never`, so no request drives an unbounded
97
+ * `= ANY($values)` parameter. An application's own bound sits below it:
98
+ * `@thomasfosterau/pulpit-wordpress` caps each list parameter at 100, a cap
99
+ * WordPress core itself doesn't set (pulpit#86).
100
+ *
101
+ * @since 0.1.0
102
+ */
103
+ export const MAX_SET_VALUES = 1000;
104
+
105
+ /**
106
+ * A row as {@link evaluate} reads it: the view's columns by name.
107
+ *
108
+ * @since 0.1.0
109
+ */
110
+ export type FilterRow = { readonly [column: string]: ColumnValue };
111
+
112
+ /**
113
+ * A read predicate over a current-version view's columns.
114
+ *
115
+ * @since 0.1.0
116
+ */
117
+ export type ListFilter<Following> =
118
+ | { readonly kind: "always" }
119
+ | { readonly kind: "never" }
120
+ | { readonly kind: "eq"; readonly column: string; readonly value: Scalar }
121
+ | { readonly kind: "ne"; readonly column: string; readonly value: Scalar }
122
+ | { readonly kind: "in"; readonly column: string; readonly set: Following }
123
+ | { readonly kind: "notIn"; readonly column: string; readonly set: Following }
124
+ | { readonly kind: "gt"; readonly column: string; readonly value: Comparable }
125
+ | { readonly kind: "gte"; readonly column: string; readonly value: Comparable }
126
+ | { readonly kind: "lt"; readonly column: string; readonly value: Comparable }
127
+ | { readonly kind: "lte"; readonly column: string; readonly value: Comparable }
128
+ | { readonly kind: "anyOf"; readonly column: string; readonly values: ReadonlyArray<Scalar> }
129
+ | { readonly kind: "noneOf"; readonly column: string; readonly values: ReadonlyArray<Scalar> }
130
+ | { readonly kind: "isNull"; readonly column: string }
131
+ | { readonly kind: "published"; readonly column: string }
132
+ | { readonly kind: "notPublished"; readonly column: string }
133
+ | { readonly kind: "locale"; readonly column: string; readonly locales: ReadonlyArray<string> }
134
+ | { readonly kind: "and"; readonly filters: ReadonlyArray<ListFilter<Following>> }
135
+ | { readonly kind: "or"; readonly filters: ReadonlyArray<ListFilter<Following>> };
136
+
137
+ /**
138
+ * No predicate: every row.
139
+ *
140
+ * @since 0.1.0
141
+ */
142
+ export const always = { kind: "always" } as const;
143
+
144
+ /**
145
+ * Fail closed: no row.
146
+ *
147
+ * @since 0.1.0
148
+ */
149
+ export const never = { kind: "never" } as const;
150
+
151
+ /**
152
+ * A `NaN`, or an invalid `Date`: a value with no place in the order SQL and
153
+ * {@link evaluate} share (see the module's "Outside the contract").
154
+ */
155
+ const isUnordered = (value: ColumnValue): boolean =>
156
+ (Predicate.isNumber(value) && Number.isNaN(value)) ||
157
+ (Predicate.isDate(value) && Number.isNaN(value.getTime()));
158
+
159
+ /**
160
+ * `column = value`. A `NaN` value is {@link never}: PostgreSQL's `NaN = NaN`
161
+ * is true where JavaScript's is not, so it fails closed.
162
+ *
163
+ * @since 0.1.0
164
+ */
165
+ export const eq = (column: string, value: Scalar) =>
166
+ isUnordered(value) ? never : ({ kind: "eq", column, value } as const);
167
+
168
+ /**
169
+ * `column <> value`. A `NULL` or absent column satisfies it no more than it
170
+ * satisfies {@link eq}: SQL's `NULL <> $v` is `NULL`, not true. A `NaN`
171
+ * value is {@link never}, failing closed.
172
+ *
173
+ * @since 0.1.0
174
+ */
175
+ export const ne = (column: string, value: Scalar) =>
176
+ isUnordered(value) ? never : ({ kind: "ne", column, value } as const);
177
+
178
+ /**
179
+ * `column IN (following)`. A `NULL` or absent column satisfies none, whatever
180
+ * `following` holds: SQL's `NULL IN (…)` is `NULL`, not true.
181
+ *
182
+ * @since 0.1.0
183
+ */
184
+ export const isIn = <Following>(column: string, set: Following) =>
185
+ ({ kind: "in", column, set }) as const;
186
+
187
+ /**
188
+ * `(column IS NULL OR column NOT IN (following))` (pulpit#191): the exact
189
+ * complement of {@link isIn} over the same seam set, so `isIn(c, s)` and
190
+ * `isNotIn(c, s)` partition every row. A `NULL` or absent column satisfies
191
+ * it, whatever `following` holds, and is never asked about; the seam's set
192
+ * must never hold a `NULL` (SQL's `NOT IN` over one is `NULL` for every row).
193
+ *
194
+ * @example
195
+ * ```ts
196
+ * import { strictEqual } from "node:assert";
197
+ * import { evaluate, isNotIn } from "@thomasfosterau/pulpit/filter";
198
+ *
199
+ * const excluded = new Set(["a"]);
200
+ * const context = { now: new Date(0), contains: (set: Set<string>, value: unknown) => set.has(String(value)) };
201
+ * strictEqual(evaluate(isNotIn("id", excluded), { id: "a" }, context), false);
202
+ * strictEqual(evaluate(isNotIn("id", excluded), { id: "b" }, context), true);
203
+ * strictEqual(evaluate(isNotIn("id", excluded), { id: null }, context), true);
204
+ * ```
205
+ *
206
+ * @since 0.1.0
207
+ */
208
+ export const isNotIn = <Following>(column: string, set: Following) =>
209
+ ({ kind: "notIn", column, set }) as const;
210
+
211
+ /**
212
+ * `column > value`. A `NULL` or absent column satisfies it no more than SQL's
213
+ * `NULL > $v` (which is `NULL`); `evaluate` reads a column holding another
214
+ * type than `value` as satisfying none, too. A `NaN` or invalid-`Date` value
215
+ * is {@link never}. See {@link Comparable} for the orders.
216
+ *
217
+ * @since 0.1.0
218
+ */
219
+ export const gt = (column: string, value: Comparable) =>
220
+ isUnordered(value) ? never : ({ kind: "gt", column, value } as const);
221
+
222
+ /**
223
+ * `column >= value`; equality satisfies it, `Infinity` against `Infinity`
224
+ * included. A `NULL` or absent column satisfies none (SQL's `NULL >= $v` is
225
+ * `NULL`); `evaluate` reads a column holding another type than `value` as
226
+ * satisfying none, too. A `NaN` or invalid-`Date` value is {@link never}.
227
+ *
228
+ * @since 0.1.0
229
+ */
230
+ export const gte = (column: string, value: Comparable) =>
231
+ isUnordered(value) ? never : ({ kind: "gte", column, value } as const);
232
+
233
+ /**
234
+ * `column < value`. A `NULL` or absent column satisfies none (SQL's
235
+ * `NULL < $v` is `NULL`); `evaluate` reads a column holding another type than
236
+ * `value` as satisfying none, too. A `NaN` or invalid-`Date` value is
237
+ * {@link never}.
238
+ *
239
+ * @since 0.1.0
240
+ */
241
+ export const lt = (column: string, value: Comparable) =>
242
+ isUnordered(value) ? never : ({ kind: "lt", column, value } as const);
243
+
244
+ /**
245
+ * `column <= value`; equality satisfies it, `-Infinity` against `-Infinity`
246
+ * included. A `NULL` or absent column satisfies none (SQL's `NULL <= $v` is
247
+ * `NULL`); `evaluate` reads a column holding another type than `value` as
248
+ * satisfying none, too. A `NaN` or invalid-`Date` value is {@link never}.
249
+ *
250
+ * @since 0.1.0
251
+ */
252
+ export const lte = (column: string, value: Comparable) =>
253
+ isUnordered(value) ? never : ({ kind: "lte", column, value } as const);
254
+
255
+ /**
256
+ * `column = ANY(values)`: the column equals one of a literal set, each tested
257
+ * as {@link eq} tests its value. A `NULL` or absent column is in no set. No
258
+ * values is {@link never}, and so are more than {@link MAX_SET_VALUES}
259
+ * (duplicates counted, `SEC-INPUT-003`) and a set holding a `NaN`, so
260
+ * {@link and} / {@link or} fold each. This is a literal set; a follower
261
+ * subquery is {@link isIn}'s.
262
+ *
263
+ * @since 0.1.0
264
+ */
265
+ export const anyOf = (column: string, values: ReadonlyArray<Scalar>) =>
266
+ Array.isReadonlyArrayEmpty(values) || values.length > MAX_SET_VALUES || values.some(isUnordered)
267
+ ? never
268
+ : ({ kind: "anyOf", column, values: [...values] } as const);
269
+
270
+ /**
271
+ * `column <> ALL(values)`: the column equals none of a literal set, each
272
+ * tested as {@link ne} tests its value. A `NULL` or absent column satisfies it
273
+ * no more than it satisfies `ne` (SQL's `NULL <> ALL(…)` is `NULL`). No values
274
+ * is {@link always}; more than {@link MAX_SET_VALUES} (duplicates counted) is
275
+ * {@link never}, failing closed rather than excluding only some
276
+ * (`SEC-INPUT-003`), and so is a set holding a `NaN`.
277
+ *
278
+ * @since 0.1.0
279
+ */
280
+ export const noneOf = (column: string, values: ReadonlyArray<Scalar>) =>
281
+ Array.isReadonlyArrayEmpty(values)
282
+ ? always
283
+ : values.length > MAX_SET_VALUES || values.some(isUnordered)
284
+ ? never
285
+ : ({ kind: "noneOf", column, values: [...values] } as const);
286
+
287
+ /**
288
+ * `column IS NULL`: the column holds no value (`evaluate` reads an absent
289
+ * column so, too). The one node a `NULL` column satisfies on its own, so
290
+ * `or(isNull(column), noneOf(column, values))` is the exclusion that keeps an
291
+ * unset column, which a bare {@link noneOf} drops (pulpit#170).
292
+ *
293
+ * @since 0.1.0
294
+ */
295
+ export const isNull = (column: string) => ({ kind: "isNull", column }) as const;
296
+
297
+ /**
298
+ * Stamped, and the stamp has arrived.
299
+ *
300
+ * @since 0.1.0
301
+ */
302
+ export const published = (column: string) => ({ kind: "published", column }) as const;
303
+
304
+ /**
305
+ * Not out: unstamped, or stamped in the future — the exact complement of {@link published}.
306
+ *
307
+ * @since 0.1.0
308
+ */
309
+ export const notPublished = (column: string) => ({ kind: "notPublished", column }) as const;
310
+
311
+ /**
312
+ * The column a localised leaf's current-version view keys its locale level
313
+ * by (pulpit#56): one view row per `(id, locale)` the head carries, the
314
+ * shared columns joined in, and a node carrying no locale as one row whose
315
+ * `locale` is `NULL`. No leaf column may take the name.
316
+ *
317
+ * @since 0.1.0
318
+ */
319
+ export const LOCALE_COLUMN = "locale";
320
+
321
+ /**
322
+ * The locale arm (pulpit#56): the view rows in one of `locales`, read from
323
+ * the view's {@link LOCALE_COLUMN} as {@link anyOf} reads its column. A row
324
+ * carrying no locale (a `NULL` locale), and every row of a view with no
325
+ * locale level, is in none. No locales is {@link never}, and so are more than
326
+ * {@link MAX_SET_VALUES} (`SEC-INPUT-003`).
327
+ *
328
+ * @example
329
+ * ```ts
330
+ * import { strictEqual } from "node:assert";
331
+ * import { evaluate, locale } from "@thomasfosterau/pulpit/filter";
332
+ *
333
+ * const context = { now: new Date(0), contains: () => false };
334
+ * strictEqual(evaluate(locale(["fr", "en"]), { id: "a", locale: "fr" }, context), true);
335
+ * strictEqual(evaluate(locale(["fr", "en"]), { id: "a", locale: "de" }, context), false);
336
+ * strictEqual(evaluate(locale(["fr"]), { id: "a" }, context), false);
337
+ * ```
338
+ *
339
+ * @since 0.1.0
340
+ */
341
+ export const locale = (locales: ReadonlyArray<string>) =>
342
+ Array.isReadonlyArrayEmpty(locales) || locales.length > MAX_SET_VALUES
343
+ ? never
344
+ : ({ kind: "locale", column: LOCALE_COLUMN, locales: [...locales] } as const);
345
+
346
+ /** No parts → the identity; one part → that part, bare; more → the combined node. */
347
+ const collapse = <Following>(
348
+ parts: ReadonlyArray<ListFilter<Following>>,
349
+ identity: ListFilter<Following>,
350
+ combine: (parts: ReadonlyArray<ListFilter<Following>>) => ListFilter<Following>,
351
+ ): ListFilter<Following> =>
352
+ Array.match(parts, {
353
+ onEmpty: () => identity,
354
+ onNonEmpty: (nonEmpty) =>
355
+ Array.isReadonlyArrayNonEmpty(Array.tailNonEmpty(nonEmpty))
356
+ ? combine(nonEmpty)
357
+ : Array.headNonEmpty(nonEmpty),
358
+ });
359
+
360
+ /**
361
+ * Conjunction. `always` parts drop out, a `never` part makes the whole
362
+ * `never`, and a single remaining part is returned bare — so composing
363
+ * optional filters reads as one call, the shape the apps' `combine` had.
364
+ *
365
+ * @since 0.1.0
366
+ */
367
+ export const and = <Following>(
368
+ ...filters: ReadonlyArray<ListFilter<Following>>
369
+ ): ListFilter<Following> => {
370
+ if (filters.some((filter) => filter.kind === "never")) return never;
371
+ return collapse(
372
+ Array.filter(filters, (filter) => filter.kind !== "always"),
373
+ always,
374
+ (parts) => ({ kind: "and", filters: parts }),
375
+ );
376
+ };
377
+
378
+ /**
379
+ * Disjunction. `never` parts drop out, an `always` part makes the whole
380
+ * `always`, and a single remaining part is returned bare.
381
+ *
382
+ * @since 0.1.0
383
+ */
384
+ export const or = <Following>(
385
+ ...filters: ReadonlyArray<ListFilter<Following>>
386
+ ): ListFilter<Following> => {
387
+ if (filters.some((filter) => filter.kind === "always")) return always;
388
+ return collapse(
389
+ Array.filter(filters, (filter) => filter.kind !== "never"),
390
+ never,
391
+ (parts) => ({ kind: "or", filters: parts }),
392
+ );
393
+ };
394
+
395
+ /**
396
+ * What {@link evaluate} needs besides the row: the query's clock and the `in` test.
397
+ *
398
+ * @since 0.1.0
399
+ */
400
+ export interface EvaluationContext<Following> {
401
+ /** The instant `published` / `notPublished` compare against — the query's `now()`. */
402
+ readonly now: Date;
403
+ /**
404
+ * Whether `value` is a member of the follower seam's `set`. {@link evaluate}
405
+ * never asks it about a `null` or absent column: such a row satisfies no
406
+ * `in`, and every `notIn`, whatever this would answer.
407
+ */
408
+ readonly contains: (set: Following, value: ColumnValue) => boolean;
409
+ }
410
+
411
+ /**
412
+ * Numbers by `<` / `>`, never by subtraction: `Infinity - Infinity` is `NaN`,
413
+ * which would read `Infinity >= Infinity` as false. Either side `NaN` (or an
414
+ * invalid `Date`'s instant) is `NaN`, which every relational test reads as false.
415
+ */
416
+ const byNumber = (column: number, value: number): number =>
417
+ Number.isNaN(column) || Number.isNaN(value) ? Number.NaN : Order.Number(column, value);
418
+
419
+ /**
420
+ * Compare a column with a {@link Comparable}: negative, zero or positive, or
421
+ * `NaN` when the column is `NULL`, absent, of another type or unordered
422
+ * (`NaN`, an invalid `Date`), which every relational test then reads as
423
+ * false, as SQL's `NULL` is.
424
+ */
425
+ const compareTo = (column: ColumnValue, value: Comparable): number => {
426
+ if (Predicate.isDate(value)) {
427
+ return Predicate.isDate(column) ? byNumber(column.getTime(), value.getTime()) : Number.NaN;
428
+ }
429
+ if (Predicate.isString(value)) {
430
+ return Predicate.isString(column) ? byCodePoint(column, value) : Number.NaN;
431
+ }
432
+ return Predicate.isNumber(column) ? byNumber(column, value) : Number.NaN;
433
+ };
434
+
435
+ /**
436
+ * `column <> value` as SQL reads it where the two readings agree: a `NULL`
437
+ * column differs from nothing (`NULL <> $v` is `NULL`), and outside the
438
+ * contract — a column of another type than `value`, or a `NaN` on either
439
+ * side — it fails closed rather than answer true.
440
+ */
441
+ const differs = (column: ColumnValue, value: Scalar): boolean =>
442
+ sameKind(column, value) && !isUnordered(column) && !isUnordered(value) && column !== value;
443
+
444
+ /** Whether `column` holds the same kind of {@link Scalar} as `value`. */
445
+ const sameKind = (column: ColumnValue, value: Scalar): boolean =>
446
+ Predicate.isString(value)
447
+ ? Predicate.isString(column)
448
+ : Predicate.isNumber(value)
449
+ ? Predicate.isNumber(column)
450
+ : Predicate.isBoolean(column);
451
+
452
+ /** Read a timestamp column the way the publication axis does: a `Date`, or nothing. */
453
+ const instantOf = (value: ColumnValue): number =>
454
+ value instanceof Date ? value.getTime() : Number.NaN;
455
+
456
+ /**
457
+ * Run a filter against one row — the in-memory reading of the rendering
458
+ * contract above. A column that is `null` or that the row lacks matches no
459
+ * `eq`, satisfies no `ne`, `in`, comparison, `anyOf` or non-empty `noneOf`,
460
+ * satisfies every `notIn`, and is unpublished: SQL reads `NULL = $v`, `NULL <> $v`, `NULL IN (…)`,
461
+ * `NULL > $v` and `NULL <> ALL(…)` as `NULL`, which a `WHERE` drops, so `ne`
462
+ * is not the complement of `eq`, nor `noneOf` of `anyOf`, over a nullable
463
+ * column; {@link isNull} is the one node such a row satisfies. Such a row fails an `in` without `context.contains` being asked, so
464
+ * the answer never depends on the seam (pulpit#94). A set past
465
+ * {@link MAX_SET_VALUES} is `never`. Outside the contract it fails closed: a
466
+ * column of another type than the comparand (or than any of a set's values),
467
+ * and a `NaN` (or invalid `Date`) comparand, set element or column, satisfy
468
+ * no `eq`, `ne`, comparison, `anyOf` or non-empty `noneOf`.
469
+ *
470
+ * @since 0.1.0
471
+ */
472
+ export const evaluate = <Following>(
473
+ filter: ListFilter<Following>,
474
+ row: FilterRow,
475
+ context: EvaluationContext<Following>,
476
+ ): boolean => {
477
+ const arrived = (column: string) => instantOf(row[column] ?? null) <= context.now.getTime();
478
+ return Match.value(filter).pipe(
479
+ Match.discriminatorsExhaustive("kind")({
480
+ always: () => true,
481
+ never: () => false,
482
+ // `NaN === NaN` is false, so a `NaN` on either side already matches nothing.
483
+ eq: (node) => row[node.column] === node.value,
484
+ ne: (node) => differs(row[node.column] ?? null, node.value),
485
+ in: (node) => {
486
+ const value = row[node.column] ?? null;
487
+ return value !== null && context.contains(node.set, value);
488
+ },
489
+ notIn: (node) => {
490
+ const value = row[node.column] ?? null;
491
+ return value === null || !context.contains(node.set, value);
492
+ },
493
+ gt: (node) => compareTo(row[node.column] ?? null, node.value) > 0,
494
+ gte: (node) => compareTo(row[node.column] ?? null, node.value) >= 0,
495
+ lt: (node) => compareTo(row[node.column] ?? null, node.value) < 0,
496
+ lte: (node) => compareTo(row[node.column] ?? null, node.value) <= 0,
497
+ anyOf: (node) =>
498
+ node.values.length <= MAX_SET_VALUES &&
499
+ !node.values.some(isUnordered) &&
500
+ node.values.some((value) => row[node.column] === value),
501
+ noneOf: (node) => {
502
+ if (node.values.length > MAX_SET_VALUES) return false;
503
+ // `<> ALL` over no values is true, even for NULL; the constructor folds that to `always`.
504
+ if (Array.isReadonlyArrayEmpty(node.values)) return true;
505
+ const value = row[node.column] ?? null;
506
+ return node.values.every((excluded) => differs(value, excluded));
507
+ },
508
+ isNull: (node) => (row[node.column] ?? null) === null,
509
+ published: (node) => arrived(node.column),
510
+ notPublished: (node) => !arrived(node.column),
511
+ locale: (node) => {
512
+ const value = row[node.column] ?? null;
513
+ return (
514
+ node.locales.length <= MAX_SET_VALUES &&
515
+ Predicate.isString(value) &&
516
+ node.locales.includes(value)
517
+ );
518
+ },
519
+ and: (node) => node.filters.every((part) => evaluate(part, row, context)),
520
+ or: (node) => node.filters.some((part) => evaluate(part, row, context)),
521
+ }),
522
+ );
523
+ };
package/src/index.ts ADDED
@@ -0,0 +1,153 @@
1
+ /**
2
+ * `@thomasfosterau/pulpit` — the versioned-content kernel.
3
+ *
4
+ * Every module is exported as a namespace from the package root, following
5
+ * the convention of `effect` and `@thomasfosterau/effect-jsonapi`; each is
6
+ * also a subpath (`@thomasfosterau/pulpit/content`, …) for a consumer that
7
+ * wants one module without the barrel. The in-memory fakes are the one
8
+ * exception: they are the subpath `@thomasfosterau/pulpit/testing` only, so
9
+ * that production code never pulls them in through the barrel.
10
+ *
11
+ * @copyright Thomas Foster
12
+ * @module pulpit
13
+ */
14
+
15
+ /**
16
+ * The Content model fields: `Visibility`, the visibility / draft / timestamp /
17
+ * tombstone spreads, `ownerRef`, the base `content` resource.
18
+ */
19
+ export * as Content from "./content.ts";
20
+
21
+ /**
22
+ * The registry flag vocabulary, `validateRegistry`, and the derived families /
23
+ * branches.
24
+ */
25
+ export * as Registry from "./registry.ts";
26
+
27
+ /**
28
+ * The append-only node / version chain: create, update, restore, delete,
29
+ * getVersion, compare (with the pure `diffStates`), restoreVersion, history —
30
+ * over a store seam.
31
+ */
32
+ export * as VersionChain from "./version-chain.ts";
33
+
34
+ /** The list-filter vocabulary the read predicates render into, and its in-memory evaluator. */
35
+ export * as Filter from "./filter.ts";
36
+
37
+ /**
38
+ * The subtype operation factory: `LeafAccess`, the read scope and viewer
39
+ * predicates, the write gates, `defineSubtype` and its handlers seam.
40
+ */
41
+ export * as Subtype from "./subtype.ts";
42
+
43
+ /**
44
+ * The publication axis, the moderation state, and the cache serveability
45
+ * policy (`isPubliclyServeable`, the `serveable` hook).
46
+ */
47
+ export * as Publication from "./publication.ts";
48
+
49
+ /**
50
+ * The content-addressed document store seam (`DocumentStore`), the
51
+ * `DocumentHash` it is keyed by, and the `documentRef` field spread a version
52
+ * row carries the reference in.
53
+ */
54
+ export * as Documents from "./documents.ts";
55
+
56
+ /**
57
+ * The edge operations factory: a relation between nodes as its own family
58
+ * (`defineEdgeOps` over an `EdgeStore` seam), readable only when every end
59
+ * is, with declared cardinality limits.
60
+ */
61
+ export * as Edges from "./edges.ts";
62
+
63
+ /**
64
+ * Suggested edits: a contributor's patch against a node's version, accepted
65
+ * through the target's own update or rejected (`defineProposals` over a
66
+ * `ProposalStore` seam), with its two-tier stale / conflict classification.
67
+ */
68
+ export * as Proposals from "./proposals.ts";
69
+
70
+ /**
71
+ * File and media leaves: the blob-store seam (`BlobStore`) keyed by the
72
+ * content hash, the file field spread, the commit-ordered byte write
73
+ * (`defineFileWrites`) and the usage index's edge ends.
74
+ */
75
+ export * as Blobs from "./blobs.ts";
76
+
77
+ /**
78
+ * Routable content: a leaf's path claims tied to its version chain
79
+ * (`routable` over a `PathStore` seam), the aliases a path change leaves
80
+ * behind, and the gated resolution of a path.
81
+ */
82
+ export * as Paths from "./paths.ts";
83
+
84
+ /**
85
+ * The moderation workflow: the transition rules as a before hook
86
+ * (`moderationHook`), the decision (`defineModeration`), the review-queue
87
+ * filter, threshold grading (`gradeScores`) and member reports
88
+ * (`defineReports` over a `ReportStore` seam).
89
+ */
90
+ export * as Moderation from "./moderation.ts";
91
+
92
+ /**
93
+ * The working copy: an editor's autosave buffer outside the version chain
94
+ * (`WorkingCopyStore`), committed as one ordinary version
95
+ * (`defineWorkingCopy`).
96
+ */
97
+ export * as WorkingCopy from "./working-copy.ts";
98
+
99
+ /**
100
+ * The comments family: threaded comments on any node as versioned owned
101
+ * leaves (`defineComments`), behind the target's read gate and the
102
+ * moderation gate.
103
+ */
104
+ export * as Comments from "./comments.ts";
105
+
106
+ /**
107
+ * Purge: erasure, the one destructive path through a node's history. It
108
+ * hard-deletes a node's chain and the rows kept for it, then the documents
109
+ * and bytes nothing else references (`definePurge` over a `PurgeStore`).
110
+ */
111
+ export * as Purge from "./purge.ts";
112
+
113
+ /**
114
+ * Hybrid search: reciprocal rank fusion over ranked branches, the reranker
115
+ * and branch seams every branch's scope runs through, and the embedding
116
+ * pipeline as seams (`fuseRankings`, `hybridSearch`, `embedPending`).
117
+ */
118
+ export * as Search from "./search.ts";
119
+
120
+ /**
121
+ * Write budgets: a declared ceiling on an account's writes in a rolling
122
+ * window, spent inside the write as a before hook or by a plain-row family,
123
+ * over a `BudgetCounter` seam (`bindBudget`, `budgetHook`).
124
+ */
125
+ export * as Budgets from "./budgets.ts";
126
+
127
+ /**
128
+ * Reactions: one reaction per account per node, a kind from the family's
129
+ * vocabulary, behind the target's read gate, with a grouped tally
130
+ * (`defineReactions` over a `ReactionStore`).
131
+ */
132
+ export * as Reactions from "./reactions.ts";
133
+
134
+ /**
135
+ * The reference resource cache: `./subtype`'s `ResourceCacheSeam`, with a
136
+ * read-through `get`, over a minimal versioned key-value seam
137
+ * (`makeResourceCache` over a `VersionedKv`).
138
+ */
139
+ export * as Cache from "./cache.ts";
140
+
141
+ /**
142
+ * Ordered collections: positioned members of a parent node, the order kept
143
+ * beside the chain or versioned with the parent (`defineCollection` over a
144
+ * `CollectionStore`).
145
+ */
146
+ export * as Collections from "./collections.ts";
147
+
148
+ /**
149
+ * The social graph: follow, mute and block between accounts, the follower
150
+ * seam filled from it, and the opt-in read filter over silenced authors
151
+ * (`defineSocial` over a `SocialStore`).
152
+ */
153
+ export * as Social from "./social.ts";