@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.
- package/LICENSE +21 -0
- package/README.md +1271 -1
- package/dist/blobs.d.ts +249 -0
- package/dist/blobs.d.ts.map +1 -0
- package/dist/blobs.js +193 -0
- package/dist/blobs.js.map +1 -0
- package/dist/budgets.d.ts +255 -0
- package/dist/budgets.d.ts.map +1 -0
- package/dist/budgets.js +211 -0
- package/dist/budgets.js.map +1 -0
- package/dist/cache.d.ts +154 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +118 -0
- package/dist/cache.js.map +1 -0
- package/dist/collections.d.ts +242 -0
- package/dist/collections.d.ts.map +1 -0
- package/dist/collections.js +215 -0
- package/dist/collections.js.map +1 -0
- package/dist/comments.d.ts +212 -0
- package/dist/comments.d.ts.map +1 -0
- package/dist/comments.js +192 -0
- package/dist/comments.js.map +1 -0
- package/dist/content.d.ts +231 -0
- package/dist/content.d.ts.map +1 -0
- package/dist/content.js +204 -0
- package/dist/content.js.map +1 -0
- package/dist/documents.d.ts +184 -0
- package/dist/documents.d.ts.map +1 -0
- package/dist/documents.js +190 -0
- package/dist/documents.js.map +1 -0
- package/dist/edges.d.ts +645 -0
- package/dist/edges.d.ts.map +1 -0
- package/dist/edges.js +558 -0
- package/dist/edges.js.map +1 -0
- package/dist/filter.d.ts +461 -0
- package/dist/filter.d.ts.map +1 -0
- package/dist/filter.js +387 -0
- package/dist/filter.js.map +1 -0
- package/dist/index.d.ts +133 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +133 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/filter-columns.d.ts +8 -0
- package/dist/internal/filter-columns.d.ts.map +1 -0
- package/dist/internal/filter-columns.js +43 -0
- package/dist/internal/filter-columns.js.map +1 -0
- package/dist/internal/list-order.d.ts +26 -0
- package/dist/internal/list-order.d.ts.map +1 -0
- package/dist/internal/list-order.js +92 -0
- package/dist/internal/list-order.js.map +1 -0
- package/dist/internal/owned-columns.d.ts +93 -0
- package/dist/internal/owned-columns.d.ts.map +1 -0
- package/dist/internal/owned-columns.js +98 -0
- package/dist/internal/owned-columns.js.map +1 -0
- package/dist/internal/plain-rows.d.ts +32 -0
- package/dist/internal/plain-rows.d.ts.map +1 -0
- package/dist/internal/plain-rows.js +32 -0
- package/dist/internal/plain-rows.js.map +1 -0
- package/dist/internal/reference-columns.d.ts +80 -0
- package/dist/internal/reference-columns.d.ts.map +1 -0
- package/dist/internal/reference-columns.js +102 -0
- package/dist/internal/reference-columns.js.map +1 -0
- package/dist/moderation.d.ts +392 -0
- package/dist/moderation.d.ts.map +1 -0
- package/dist/moderation.js +375 -0
- package/dist/moderation.js.map +1 -0
- package/dist/paths.d.ts +204 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +165 -0
- package/dist/paths.js.map +1 -0
- package/dist/proposals.d.ts +523 -0
- package/dist/proposals.d.ts.map +1 -0
- package/dist/proposals.js +505 -0
- package/dist/proposals.js.map +1 -0
- package/dist/publication.d.ts +148 -0
- package/dist/publication.d.ts.map +1 -0
- package/dist/publication.js +183 -0
- package/dist/publication.js.map +1 -0
- package/dist/purge.d.ts +224 -0
- package/dist/purge.d.ts.map +1 -0
- package/dist/purge.js +151 -0
- package/dist/purge.js.map +1 -0
- package/dist/reactions.d.ts +246 -0
- package/dist/reactions.d.ts.map +1 -0
- package/dist/reactions.js +183 -0
- package/dist/reactions.js.map +1 -0
- package/dist/registry.d.ts +316 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +305 -0
- package/dist/registry.js.map +1 -0
- package/dist/search.d.ts +366 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/search.js +273 -0
- package/dist/search.js.map +1 -0
- package/dist/social.d.ts +311 -0
- package/dist/social.d.ts.map +1 -0
- package/dist/social.js +205 -0
- package/dist/social.js.map +1 -0
- package/dist/subtype.d.ts +1613 -0
- package/dist/subtype.d.ts.map +1 -0
- package/dist/subtype.js +1300 -0
- package/dist/subtype.js.map +1 -0
- package/dist/testing.d.ts +675 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +1022 -0
- package/dist/testing.js.map +1 -0
- package/dist/version-chain.d.ts +702 -0
- package/dist/version-chain.d.ts.map +1 -0
- package/dist/version-chain.js +489 -0
- package/dist/version-chain.js.map +1 -0
- package/dist/working-copy.d.ts +116 -0
- package/dist/working-copy.d.ts.map +1 -0
- package/dist/working-copy.js +95 -0
- package/dist/working-copy.js.map +1 -0
- package/package.json +138 -4
- package/src/blobs.ts +384 -0
- package/src/budgets.ts +338 -0
- package/src/cache.ts +218 -0
- package/src/collections.ts +427 -0
- package/src/comments.ts +392 -0
- package/src/content.ts +238 -0
- package/src/documents.ts +284 -0
- package/src/edges.ts +1185 -0
- package/src/filter.ts +523 -0
- package/src/index.ts +153 -0
- package/src/internal/filter-columns.ts +50 -0
- package/src/internal/list-order.ts +117 -0
- package/src/internal/owned-columns.ts +140 -0
- package/src/internal/plain-rows.ts +54 -0
- package/src/internal/reference-columns.ts +156 -0
- package/src/moderation.ts +728 -0
- package/src/paths.ts +267 -0
- package/src/proposals.ts +1014 -0
- package/src/publication.ts +225 -0
- package/src/purge.ts +313 -0
- package/src/reactions.ts +347 -0
- package/src/registry.ts +524 -0
- package/src/search.ts +540 -0
- package/src/social.ts +469 -0
- package/src/subtype.ts +3095 -0
- package/src/testing.ts +1873 -0
- package/src/version-chain.ts +1093 -0
- 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
|
+
};
|