@thomasfosterau/pulpit 0.0.0-stage → 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 +1272 -2
  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 +145 -3
  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
@@ -0,0 +1,50 @@
1
+ /**
2
+ * **A list filter's columns, renamed**: `./edges` qualifies a leaf's filter
3
+ * with an end's alias through it, so a store can render that filter against
4
+ * the view it joined under the alias.
5
+ *
6
+ * Internal: not a package entry point (absent from `package.json`
7
+ * `exports`) and excluded from the generated API docs (`docgen.json`).
8
+ *
9
+ * @internal
10
+ */
11
+ import * as Match from "effect/Match";
12
+ import type { ListFilter } from "../filter.ts";
13
+
14
+ /**
15
+ * Every column of `filter` rewritten by `rename`; the tree's shape and every
16
+ * comparand are kept. Exhaustive over the node kinds, so a new kind cannot
17
+ * slip through with its column unrenamed.
18
+ */
19
+ export const renameColumns = <Following>(
20
+ filter: ListFilter<Following>,
21
+ rename: (column: string) => string,
22
+ ): ListFilter<Following> =>
23
+ Match.value(filter).pipe(
24
+ Match.discriminatorsExhaustive("kind")({
25
+ always: (node): ListFilter<Following> => node,
26
+ never: (node): ListFilter<Following> => node,
27
+ eq: (node): ListFilter<Following> => ({ ...node, column: rename(node.column) }),
28
+ ne: (node): ListFilter<Following> => ({ ...node, column: rename(node.column) }),
29
+ in: (node): ListFilter<Following> => ({ ...node, column: rename(node.column) }),
30
+ notIn: (node): ListFilter<Following> => ({ ...node, column: rename(node.column) }),
31
+ gt: (node): ListFilter<Following> => ({ ...node, column: rename(node.column) }),
32
+ gte: (node): ListFilter<Following> => ({ ...node, column: rename(node.column) }),
33
+ lt: (node): ListFilter<Following> => ({ ...node, column: rename(node.column) }),
34
+ lte: (node): ListFilter<Following> => ({ ...node, column: rename(node.column) }),
35
+ anyOf: (node): ListFilter<Following> => ({ ...node, column: rename(node.column) }),
36
+ noneOf: (node): ListFilter<Following> => ({ ...node, column: rename(node.column) }),
37
+ isNull: (node): ListFilter<Following> => ({ ...node, column: rename(node.column) }),
38
+ published: (node): ListFilter<Following> => ({ ...node, column: rename(node.column) }),
39
+ notPublished: (node): ListFilter<Following> => ({ ...node, column: rename(node.column) }),
40
+ locale: (node): ListFilter<Following> => ({ ...node, column: rename(node.column) }),
41
+ and: (node): ListFilter<Following> => ({
42
+ kind: "and",
43
+ filters: node.filters.map((part) => renameColumns(part, rename)),
44
+ }),
45
+ or: (node): ListFilter<Following> => ({
46
+ kind: "or",
47
+ filters: node.filters.map((part) => renameColumns(part, rename)),
48
+ }),
49
+ }),
50
+ );
@@ -0,0 +1,117 @@
1
+ /**
2
+ * **The kernel's value and row orders**: the code-point string order that
3
+ * `./filter`'s comparison arms share with SQL's `COLLATE "C"`, and the list
4
+ * order the in-memory `LeafStore` fake sorts by (pulpit#112), so the fake and
5
+ * the filter never compare a string two ways.
6
+ *
7
+ * Internal: not a package entry point (absent from `package.json`
8
+ * `exports`) and excluded from the generated API docs (`docgen.json`).
9
+ * Typed structurally, so it never imports `./subtype`.
10
+ *
11
+ * @internal
12
+ */
13
+ import * as Array from "effect/Array";
14
+ import * as Order from "effect/Order";
15
+ import * as Predicate from "effect/Predicate";
16
+ import type { ColumnValue } from "../version-chain.ts";
17
+
18
+ /**
19
+ * Strings by code point, as SQL's `"C"` collation orders them over UTF-8.
20
+ * JavaScript's `<` compares UTF-16 code units instead, which puts an astral
21
+ * character (a surrogate pair, from U+D800) before U+E000–U+FFFF.
22
+ */
23
+ export const byCodePoint: Order.Order<string> = Order.mapInput(
24
+ Order.Array(Order.Number),
25
+ (text: string) => Array.map(Array.fromIterable(text), (char) => char.codePointAt(0) ?? 0),
26
+ );
27
+
28
+ /**
29
+ * Numbers as PostgreSQL's `float8` sorts them: `NaN` equal to itself and
30
+ * after every number, where Effect's `Order.Number` puts it first.
31
+ */
32
+ const byNumber: Order.Order<number> = Order.make((self, that) => {
33
+ if (Number.isNaN(self)) return Number.isNaN(that) ? 0 : 1;
34
+ return Number.isNaN(that) ? -1 : Order.Number(self, that);
35
+ });
36
+
37
+ /**
38
+ * One kind of sort-key value: its guard and its order, the order lifted to
39
+ * every {@link ColumnValue} (two values the guard doesn't both admit tie).
40
+ */
41
+ const kind = <A extends ColumnValue>(
42
+ is: (value: ColumnValue) => value is A,
43
+ order: Order.Order<A>,
44
+ ) => ({
45
+ is,
46
+ order: Order.make<ColumnValue>((self, that) => (is(self) && is(that) ? order(self, that) : 0)),
47
+ });
48
+
49
+ /**
50
+ * The kinds of sort-key value, each sorted as PostgreSQL sorts its type: a
51
+ * boolean `false` first, a number numerically, a `bigint` by value, a
52
+ * string by code point, a `Date` by its instant (an invalid one as `NaN`).
53
+ */
54
+ const kinds = [
55
+ kind(Predicate.isBoolean, Order.Boolean),
56
+ kind(Predicate.isNumber, byNumber),
57
+ kind(Predicate.isBigInt, Order.BigInt),
58
+ kind(Predicate.isString, byCodePoint),
59
+ kind(
60
+ Predicate.isDate,
61
+ Order.mapInput(byNumber, (date: Date) => date.getTime()),
62
+ ),
63
+ ];
64
+
65
+ /**
66
+ * A value's place in {@link kinds}; a JSON object or array comes after
67
+ * them all. Two values of different kinds sort by it, which is outside the
68
+ * contract (a SQL column has one type), so it only keeps the fake's order
69
+ * total and deterministic.
70
+ */
71
+ const kindOf = (value: ColumnValue): number => {
72
+ const index = kinds.findIndex((each) => each.is(value));
73
+ return index === -1 ? kinds.length : index;
74
+ };
75
+
76
+ /**
77
+ * Two non-`NULL` sort-key values: by kind, then by that kind's order. Two
78
+ * JSON objects or arrays tie, leaving the order to the next key.
79
+ */
80
+ const byValue: Order.Order<ColumnValue> = Order.make((self, that) => {
81
+ const left = kindOf(self);
82
+ const right = kindOf(that);
83
+ if (left !== right) return Order.Number(left, right);
84
+ const same = kinds[left];
85
+ return same === undefined ? 0 : same.order(self, that);
86
+ });
87
+
88
+ /** One order key, structurally (`./subtype`'s `ListOrder` fits). */
89
+ interface OrderKey {
90
+ readonly column: string;
91
+ readonly direction: "asc" | "desc";
92
+ }
93
+
94
+ /** A row as the order reads it: its `id` and its columns by name. */
95
+ type SortRow = { readonly id: string; readonly [column: string]: ColumnValue };
96
+
97
+ /** One key: its column's values in its direction, a `NULL` or absent column last in both. */
98
+ const byKey = (key: OrderKey): Order.Order<SortRow> => {
99
+ const values = key.direction === "desc" ? Order.flip(byValue) : byValue;
100
+ return Order.make((self, that) => {
101
+ const left = self[key.column] ?? null;
102
+ const right = that[key.column] ?? null;
103
+ if (left === null) return right === null ? 0 : 1;
104
+ return right === null ? -1 : values(left, right);
105
+ });
106
+ };
107
+
108
+ /** The closing key every list order ends with: `id` ascending, by code point. */
109
+ const byId: Order.Order<SortRow> = Order.mapInput(byCodePoint, (row: SortRow) => row.id);
110
+
111
+ /**
112
+ * The total order a list renders as `ORDER BY <key> <direction> NULLS LAST,
113
+ * …, id ASC`: each key in turn, a `NULL` (or absent) column after every
114
+ * value in either direction, then `id` ascending, so no two rows tie.
115
+ */
116
+ export const rowOrder = (keys: ReadonlyArray<OrderKey>): Order.Order<SortRow> =>
117
+ Order.combineAll([...Array.map(keys, byKey), byId]);
@@ -0,0 +1,140 @@
1
+ /**
2
+ * **The owned leaf's row reading**, shared by `./subtype` and `./edges` so
3
+ * the two cannot read the same view or row differently: the declared-column
4
+ * check (pulpit#79), the facts read off a row (pulpit#80), and the predicate
5
+ * over those facts, which `canViewOwned` delegates to (pulpit#85).
6
+ *
7
+ * Internal: not a package entry point (absent from `package.json`
8
+ * `exports`) and excluded from the generated API docs (`docgen.json`).
9
+ * Typed structurally, so it never imports `./subtype`.
10
+ *
11
+ * @internal
12
+ */
13
+ import * as Effect from "effect/Effect";
14
+ import * as Option from "effect/Option";
15
+ import * as Predicate from "effect/Predicate";
16
+ import * as Schema from "effect/Schema";
17
+ import { Visibility } from "../content.ts";
18
+ import type { ColumnValue } from "../version-chain.ts";
19
+
20
+ /** The owned access declaration's column names, structurally (`./subtype`'s owned `LeafAccess` fits). */
21
+ interface OwnedColumns {
22
+ readonly ownerColumn: string;
23
+ readonly visibilityColumn: string;
24
+ readonly draftColumn: Option.Option<string>;
25
+ readonly moderationColumn: Option.Option<string>;
26
+ }
27
+
28
+ /**
29
+ * The facts {@link canViewOwnedRow} reads off an owned row: `./subtype`'s
30
+ * `OwnedFacts`, except that the owner may be absent. A row whose owner
31
+ * column holds no string has no owner (pulpit#85).
32
+ */
33
+ interface OwnedRowFacts {
34
+ readonly ownerId: Option.Option<string>;
35
+ readonly visibility: Visibility;
36
+ readonly draft: boolean;
37
+ /** Under review or rejected: a moderated row whose state is not exactly `approved` (pulpit#154). */
38
+ readonly unapproved: boolean;
39
+ }
40
+
41
+ /** The parts of `./subtype`'s viewer, policy and follower seam the predicate reads, structurally. */
42
+ interface Account {
43
+ readonly id: string;
44
+ }
45
+ interface PrivateRung<A> {
46
+ readonly canViewPrivate: (account: A) => boolean;
47
+ }
48
+ interface IsFollower<R> {
49
+ readonly isFollower: (followerId: string, ownerId: string) => Effect.Effect<boolean, never, R>;
50
+ }
51
+
52
+ const isVisibility = Schema.is(Visibility);
53
+
54
+ /**
55
+ * Whether the view carries every column an owned leaf declares (owner,
56
+ * visibility and, when declared, draft and moderation state). An owned leaf whose view lacks one
57
+ * reads no row: `ownedListFilter` lists nothing, and `defineSubtype`'s
58
+ * `get` / `getMany` and `./edges`' `canViewEndpoint` show nothing. Fail
59
+ * closed (`SEC-AUTH-ENUM-001`). Never throws.
60
+ *
61
+ * @internal
62
+ */
63
+ export const declaresColumns = (access: OwnedColumns, viewColumns: ReadonlySet<string>): boolean =>
64
+ [
65
+ access.ownerColumn,
66
+ access.visibilityColumn,
67
+ ...Option.toArray(access.draftColumn),
68
+ ...Option.toArray(access.moderationColumn),
69
+ ].every((column) => viewColumns.has(column));
70
+
71
+ /**
72
+ * Read an owned row's facts for `canViewOwned`, in `defineSubtype`'s
73
+ * `get` / `getMany` and `./edges`' `canViewEndpoint`. Each reading fails
74
+ * closed where `ownedListFilter` does (`SEC-AUTH-ENUM-001`): a visibility
75
+ * outside the enum (a dropped projection, a schema drift) reads as
76
+ * `private`, since the filter's `eq(visibility, "public")` arm lists only
77
+ * `public`; a draft column holding anything but an explicit `false` (a
78
+ * `NULL`, an absent key, a value outside the type) reads as a draft, since
79
+ * its `eq(draft, false)` arms list only `false`. A leaf without the draft
80
+ * axis reads `draft: false`. A moderation column holding anything but
81
+ * exactly `"approved"` reads as unapproved, since the filter's
82
+ * `eq(moderation, "approved")` arms list only `approved`; a leaf without the
83
+ * axis reads `unapproved: false`. An owner column holding anything but a string
84
+ * (a `NULL`, an absent key, a value outside the type) reads as no owner,
85
+ * never as its `String` form (pulpit#85; see pulpit#75): its `eq(owner, …)`
86
+ * and `in(owner, following)` arms match no `NULL`. Callers check
87
+ * {@link declaresColumns} first. Never throws.
88
+ *
89
+ * @internal
90
+ */
91
+ export const ownedFacts = (
92
+ access: OwnedColumns,
93
+ row: { readonly [column: string]: ColumnValue },
94
+ ): OwnedRowFacts => ({
95
+ ownerId: Option.liftPredicate(row[access.ownerColumn], Predicate.isString),
96
+ visibility: Option.getOrElse(
97
+ Option.liftPredicate(row[access.visibilityColumn], isVisibility),
98
+ (): Visibility => "private",
99
+ ),
100
+ draft: Option.match(access.draftColumn, {
101
+ onNone: () => false,
102
+ onSome: (column) => row[column] !== false,
103
+ }),
104
+ unapproved: Option.match(access.moderationColumn, {
105
+ onNone: () => false,
106
+ onSome: (column) => row[column] !== "approved",
107
+ }),
108
+ });
109
+
110
+ /**
111
+ * The owned-material read predicate over {@link ownedFacts}: `canViewOwned`
112
+ * in `./subtype` is this with a present owner, and `defineSubtype`'s
113
+ * `get` / `getMany` and `./edges`' `canViewEndpoint` call it directly. A
114
+ * row with no owner skips the owner arm and the follower check, so no
115
+ * viewer's id matches it and the follower seam is never asked about it:
116
+ * it is visible only when it is `public`, not a draft and not unapproved, or to the
117
+ * view-private rung, as `ownedListFilter` lists it (`SEC-AUTH-ENUM-001`).
118
+ * Never fails.
119
+ *
120
+ * @internal
121
+ */
122
+ export const canViewOwnedRow = <A extends Account, R>(
123
+ viewer: Option.Option<A>,
124
+ policy: PrivateRung<A>,
125
+ followers: IsFollower<R>,
126
+ row: OwnedRowFacts,
127
+ ): Effect.Effect<boolean, never, R> => {
128
+ if (!row.draft && !row.unapproved && row.visibility === "public") return Effect.succeed(true);
129
+ if (Option.isNone(viewer)) return Effect.succeed(false);
130
+ const account = viewer.value;
131
+ if (Option.contains(row.ownerId, account.id)) return Effect.succeed(true);
132
+ if (policy.canViewPrivate(account)) return Effect.succeed(true);
133
+ // Not the owner, not privileged: a draft, or a row not approved, is hidden whatever its visibility.
134
+ if (row.draft || row.unapproved) return Effect.succeed(false);
135
+ if (row.visibility === "private") return Effect.succeed(false);
136
+ return Option.match(row.ownerId, {
137
+ onNone: () => Effect.succeed(false),
138
+ onSome: (ownerId) => followers.isFollower(account.id, ownerId),
139
+ });
140
+ };
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The pieces `./proposals` and `./moderation`'s reports share: both are
3
+ * plain-row families (not nodes) with a sign-in gate, a write clock and a
4
+ * page query narrowed to one leaf type, an optional target and an
5
+ * optional status.
6
+ *
7
+ * @module pulpit/internal/plain-rows
8
+ */
9
+
10
+ import * as Clock from "effect/Clock";
11
+ import * as DateTime from "effect/DateTime";
12
+ import * as Effect from "effect/Effect";
13
+ import * as Option from "effect/Option";
14
+ import { always, and, eq, type ListFilter } from "../filter.ts";
15
+ import { MAX_PAGE_LIMIT, Unauthorized, type Viewer, type ViewerAccount } from "../subtype.ts";
16
+
17
+ /** The write clock: the current instant, as the `Date` a row stores. */
18
+ export const nowDate: Effect.Effect<Date> = Effect.map(Clock.currentTimeMillis, (millis) =>
19
+ DateTime.toDate(DateTime.makeUnsafe(millis)),
20
+ );
21
+
22
+ /** The signed-in account, or `Unauthorized`. */
23
+ export const signedIn = <R>(
24
+ viewer: Effect.Effect<Viewer, never, R>,
25
+ ): Effect.Effect<ViewerAccount, Unauthorized, R> =>
26
+ Effect.flatMap(viewer, (account) => Effect.fromOption(account, () => new Unauthorized()));
27
+
28
+ /**
29
+ * A list request's store query: the leaf's rows under the viewer's `scope`,
30
+ * narrowed by target and status when asked, the page clamped to
31
+ * `MAX_PAGE_LIMIT` (`SEC-INPUT-003`).
32
+ */
33
+ export const plainRowQuery = <Status extends string>(
34
+ type: string,
35
+ scope: ListFilter<never>,
36
+ request: {
37
+ readonly targetId: Option.Option<string>;
38
+ readonly status: Option.Option<Status>;
39
+ readonly limit: number;
40
+ readonly offset: number;
41
+ },
42
+ ) => ({
43
+ where: and<never>(
44
+ eq("targetType", type),
45
+ scope,
46
+ Option.match(request.targetId, { onNone: () => always, onSome: (id) => eq("targetId", id) }),
47
+ Option.match(request.status, {
48
+ onNone: () => always,
49
+ onSome: (status) => eq("status", status),
50
+ }),
51
+ ),
52
+ limit: Math.max(1, Math.min(Math.trunc(request.limit), MAX_PAGE_LIMIT)),
53
+ offset: Math.max(0, Math.trunc(request.offset)),
54
+ });
@@ -0,0 +1,156 @@
1
+ /**
2
+ * **The reference leaf's privacy arm** (`PUB-009`), shared by `./subtype`
3
+ * and `./edges` so a reference row is read one way: as a list filter
4
+ * ({@link referenceListFilter}, behind `referencePrivacyFilter`, the subtype
5
+ * `list` and `./edges`' `endpointFilter`) and as a row predicate
6
+ * ({@link canViewReferenceRow}, behind `defineSubtype`'s `visibleRow` and
7
+ * `./edges`' `canViewEndpoint`). Before pulpit#92 each module spelled the
8
+ * predicate for itself.
9
+ *
10
+ * The row's facts are read by `ownedFacts`, so an owner-like value that is
11
+ * not a string reads as no owner and never reaches the follower seam
12
+ * (pulpit#85, pulpit#100).
13
+ *
14
+ * Internal: not a package entry point (absent from `package.json`
15
+ * `exports`) and excluded from the generated API docs (`docgen.json`).
16
+ * Typed structurally, so it never imports `./subtype`.
17
+ *
18
+ * @internal
19
+ */
20
+ import * as Effect from "effect/Effect";
21
+ import * as Match from "effect/Match";
22
+ import * as Option from "effect/Option";
23
+ import { always, and, eq, isIn, or, type ListFilter } from "../filter.ts";
24
+ import type { ColumnValue } from "../version-chain.ts";
25
+ import { ownedFacts } from "./owned-columns.ts";
26
+
27
+ /** The reference access declaration, structurally (`./subtype`'s reference `LeafAccess` fits). */
28
+ interface ReferenceColumns {
29
+ // oxlint-disable-next-line effect/prefer-option-over-null -- mirrors `./subtype`'s reference `LeafAccess`, whose optional field is `?: T | undefined` (AGENTS.md → Conventions)
30
+ readonly followersOfColumn?: string | undefined;
31
+ }
32
+
33
+ /** The parts of `./subtype`'s viewer, policy, follower seam and read scope the arm reads, structurally. */
34
+ interface Account {
35
+ readonly id: string;
36
+ }
37
+ interface PrivateRung<A> {
38
+ readonly canViewPrivate: (account: A) => boolean;
39
+ }
40
+ interface IsFollower<R> {
41
+ readonly isFollower: (followerId: string, ownerId: string) => Effect.Effect<boolean, never, R>;
42
+ }
43
+ type Scope<Following> =
44
+ | { readonly kind: "all" }
45
+ | { readonly kind: "public" }
46
+ | { readonly kind: "member"; readonly userId: string; readonly following: Following };
47
+
48
+ /** The reference axis's one column; a view without it rides no privacy axis. */
49
+ const VISIBILITY = "visibility";
50
+
51
+ /**
52
+ * The column whose followers may read a `followers` row, when the leaf
53
+ * declares one and the view carries it. A declared column the view lacks
54
+ * reads as none: the followers arm then matches nothing (fail closed).
55
+ */
56
+ const followersColumn = (
57
+ access: ReferenceColumns,
58
+ viewColumns: ReadonlySet<string>,
59
+ ): Option.Option<string> =>
60
+ Option.filter(Option.fromUndefinedOr(access.followersOfColumn), (column) =>
61
+ viewColumns.has(column),
62
+ );
63
+
64
+ /**
65
+ * The reference privacy arm as a list filter: `always` over a view without a
66
+ * `visibility` column or for the view-private rung (`scope.kind === "all"`);
67
+ * otherwise `visibility = 'public'`, and for a signed-in viewer also
68
+ * `visibility = 'followers' AND (<followersOfColumn> = <viewer> OR
69
+ * <followersOfColumn> IN (<following>))` when the leaf declares that column
70
+ * and the view carries it (pulpit#92): the account the column names and its
71
+ * followers read its `followers` rows. Its `private` rows stay the
72
+ * view-private rung's alone. Never throws.
73
+ *
74
+ * @internal
75
+ */
76
+ export const referenceListFilter = <Following>(
77
+ access: ReferenceColumns,
78
+ viewColumns: ReadonlySet<string>,
79
+ scope: Scope<Following>,
80
+ ): ListFilter<Following> => {
81
+ if (!viewColumns.has(VISIBILITY)) return always;
82
+ const column = followersColumn(access, viewColumns);
83
+ return Match.value(scope).pipe(
84
+ Match.discriminatorsExhaustive("kind")({
85
+ all: (): ListFilter<Following> => always,
86
+ public: (): ListFilter<Following> => eq(VISIBILITY, "public"),
87
+ member: (member): ListFilter<Following> =>
88
+ Option.match(column, {
89
+ onNone: (): ListFilter<Following> => eq(VISIBILITY, "public"),
90
+ onSome: (followed) =>
91
+ or<Following>(
92
+ eq(VISIBILITY, "public"),
93
+ and<Following>(
94
+ eq(VISIBILITY, "followers"),
95
+ or<Following>(eq(followed, member.userId), isIn(followed, member.following)),
96
+ ),
97
+ ),
98
+ }),
99
+ }),
100
+ );
101
+ };
102
+
103
+ /**
104
+ * The reference privacy arm as a row predicate, the same facts as
105
+ * {@link referenceListFilter}: a view without a `visibility` column shows
106
+ * the row; otherwise it shows when its visibility is exactly `public`, to
107
+ * the view-private rung, or, when it is exactly `followers` and the leaf
108
+ * declares a followers column the view carries, to the signed-in account
109
+ * that column names and to its followers, as the follower seam answers
110
+ * (pulpit#92). That account's `private` rows stay the view-private rung's
111
+ * alone. A column value that is not a string names no account, so it never
112
+ * matches the viewer and the seam is never asked about it (pulpit#85,
113
+ * pulpit#100). Anything else is hidden, which the
114
+ * caller answers as `NodeNotFound` (`SEC-AUTH-ENUM-001`). The soft-delete
115
+ * axis is the caller's. Never fails.
116
+ *
117
+ * @internal
118
+ */
119
+ export const canViewReferenceRow = <A extends Account, R>(
120
+ access: ReferenceColumns,
121
+ viewColumns: ReadonlySet<string>,
122
+ viewer: Option.Option<A>,
123
+ policy: PrivateRung<A>,
124
+ followers: IsFollower<R>,
125
+ row: { readonly [column: string]: ColumnValue },
126
+ ): Effect.Effect<boolean, never, R> => {
127
+ if (!viewColumns.has(VISIBILITY)) return Effect.succeed(true);
128
+ const visibility = row[VISIBILITY];
129
+ if (visibility === "public") return Effect.succeed(true);
130
+ if (Option.isNone(viewer)) return Effect.succeed(false);
131
+ const account = viewer.value;
132
+ if (policy.canViewPrivate(account)) return Effect.succeed(true);
133
+ if (visibility !== "followers") return Effect.succeed(false);
134
+ return Option.match(followersColumn(access, viewColumns), {
135
+ onNone: () => Effect.succeed(false),
136
+ onSome: (column) =>
137
+ Option.match(
138
+ ownedFacts(
139
+ {
140
+ ownerColumn: column,
141
+ visibilityColumn: VISIBILITY,
142
+ draftColumn: Option.none(),
143
+ moderationColumn: Option.none(),
144
+ },
145
+ row,
146
+ ).ownerId,
147
+ {
148
+ onNone: () => Effect.succeed(false),
149
+ onSome: (ownerId) =>
150
+ ownerId === account.id
151
+ ? Effect.succeed(true)
152
+ : followers.isFollower(account.id, ownerId),
153
+ },
154
+ ),
155
+ });
156
+ };