@fabrika/db 0.0.0-stage → 0.4.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/src/tables.ts ADDED
@@ -0,0 +1,167 @@
1
+ // @fabrika/db/tables — the table policies of `src/db/tables.ts` as data. Browser- and Worker-safe: no Bun API and no
2
+ // driver, so `@fabrika/app/contract` and the static contract reader in `@fabrika/check` import it.
3
+
4
+ import type { ColumnPolicyArtifact, TablePolicyArtifact, TableScope } from '@fabrika/auth-core'
5
+ import { freeze, namesOf, ownEntry, ownKeys, setOwn } from './builtins'
6
+ import { modelOf, type ScopeModel, type TableModel, tableModels, type Tables } from './policy'
7
+
8
+ export { PolicyError } from './errors'
9
+ export type {
10
+ AnyDbIndex,
11
+ ApplicationRows,
12
+ ApplicationTables,
13
+ AppRecordRows,
14
+ ChildRows,
15
+ ContainedRows,
16
+ CountOptions,
17
+ DataKind,
18
+ DbIndex,
19
+ FragmentTable,
20
+ IndexedTable,
21
+ ListOptions,
22
+ ListPage,
23
+ NoUnscoped,
24
+ OrderTerm,
25
+ OwnerRow,
26
+ ReadOnlyUnscoped,
27
+ ReferenceRows,
28
+ ReferenceTables,
29
+ RowFor,
30
+ RowOf,
31
+ Saved,
32
+ ScopedApplicationRows,
33
+ ScopedApplicationTables,
34
+ ScopeTarget,
35
+ TableNameOf,
36
+ TargetForm,
37
+ TargetRow,
38
+ Unavailable,
39
+ UnownedTarget,
40
+ UnscopedDeclaration,
41
+ UnscopedInsert,
42
+ UnscopedMode,
43
+ UnscopedRows,
44
+ UnscopedTables,
45
+ VersionArguments,
46
+ WithinRows,
47
+ WithinTables,
48
+ } from './orm-types'
49
+ export {
50
+ type ApplicationPolicy,
51
+ type ApplicationScope,
52
+ type ChildPolicy,
53
+ type ChildScope,
54
+ type ColumnList,
55
+ defineTables,
56
+ type DimensionPolicy,
57
+ type DimensionScope,
58
+ type InboxPolicy,
59
+ type InboxScope,
60
+ isTables,
61
+ readTables,
62
+ type RecordActions,
63
+ type ReferencePolicy,
64
+ type ReferenceScope,
65
+ type TableActions,
66
+ type TableInsert,
67
+ Tables,
68
+ type TableUpdate,
69
+ type VersionColumn,
70
+ type VocabularyTables,
71
+ } from './policy'
72
+ export type {
73
+ ColumnTypes,
74
+ DefineVocabularyPolicies,
75
+ NormalizedPolicies,
76
+ TableVocabulary,
77
+ TypedColumnList,
78
+ VocabularyAction,
79
+ VocabularyChildScope,
80
+ VocabularyContainer,
81
+ VocabularyContainerOf,
82
+ VocabularyDimension,
83
+ VocabularyDimensionScope,
84
+ VocabularyPolicyMap,
85
+ VocabularyTablePolicy,
86
+ } from './vocabulary-tables'
87
+
88
+ /** Every table's policy in the form the contract artifact carries, by table name. */
89
+ export type TablePolicies = Readonly<Record<string, TablePolicyArtifact>>
90
+
91
+ /** The artifact form of every policy `defineTables` (or `readTables`) checked. */
92
+ export function tablePolicies<DB, P>(tables: Tables<DB, P>): TablePolicies {
93
+ const models = tableModels(tables)
94
+ const names = ownKeys(models)
95
+ const artifacts: Record<string, TablePolicyArtifact> = {}
96
+ for (let index = 0; index < names.length; index++) {
97
+ const model = modelOf(models, names[index] ?? '')
98
+ if (model !== undefined) {
99
+ setOwn(artifacts, model.name, artifactOf(model))
100
+ }
101
+ }
102
+ return freeze(artifacts)
103
+ }
104
+
105
+ /** Each form inbox's columns, by table: the only fields its form writes. The artifact carries no column list. */
106
+ export type InboxColumns = Readonly<Record<string, readonly string[]>>
107
+
108
+ /** The columns of every inbox table `defineTables` (or `readTables`) checked. */
109
+ export function inboxColumns<DB, P>(tables: Tables<DB, P>): InboxColumns {
110
+ const models = tableModels(tables)
111
+ const names = ownKeys(models)
112
+ const columns: Record<string, readonly string[]> = {}
113
+ for (let index = 0; index < names.length; index++) {
114
+ const model = modelOf(models, names[index] ?? '')
115
+ if (model?.scope.mode === 'inbox') {
116
+ setOwn(columns, model.name, freeze([...namesOf(model.columns)]))
117
+ }
118
+ }
119
+ return freeze(columns)
120
+ }
121
+
122
+ function artifactOf(model: TableModel): TablePolicyArtifact {
123
+ const columns: Record<string, ColumnPolicyArtifact> = {}
124
+ const declared = [...ownKeys(model.columnReads), ...ownKeys(model.columnWrites)]
125
+ for (let index = 0; index < declared.length; index++) {
126
+ const name = declared[index] ?? ''
127
+ const read = ownEntry(model.columnReads, name)
128
+ const write = ownEntry(model.columnWrites, name)
129
+ setOwn(columns, name, { ...(read === undefined ? {} : { read }), ...(write === undefined ? {} : { write }) })
130
+ }
131
+ return {
132
+ scope: scopeOf(model.scope),
133
+ actions: {
134
+ read: model.actions.read,
135
+ insert: model.actions.insert ?? null,
136
+ update: model.actions.update ?? null,
137
+ delete: model.actions.delete ?? null,
138
+ ...(model.actions.appRead.length === 0 ? {} : { appRead: [...model.actions.appRead] }),
139
+ ...(model.actions.appCount.length === 0 ? {} : { appCount: [...model.actions.appCount] }),
140
+ },
141
+ insert: [...namesOf(model.insert)],
142
+ update: [...namesOf(model.update)],
143
+ version: model.version ?? null,
144
+ createdBy: model.createdBy ?? null,
145
+ columns,
146
+ }
147
+ }
148
+
149
+ function scopeOf(scope: ScopeModel): TableScope {
150
+ switch (scope.mode) {
151
+ case 'dimension':
152
+ return {
153
+ mode: 'dimension',
154
+ dimension: scope.dimension,
155
+ key: scope.key,
156
+ container: scope.container === undefined ? null : { dimension: scope.container.dimension, column: scope.container.column },
157
+ }
158
+ case 'child':
159
+ return { mode: 'child', parent: scope.parent, key: scope.key, column: scope.column, inherit: [...scope.inherit] }
160
+ case 'reference':
161
+ return { mode: 'reference', reason: scope.reason }
162
+ case 'inbox':
163
+ return { mode: 'inbox', form: scope.form }
164
+ case 'app':
165
+ return { mode: 'app', reason: scope.reason, key: scope.key }
166
+ }
167
+ }
@@ -0,0 +1,91 @@
1
+ // Dates and timestamps at the layer's boundary. A Postgres `date` is a calendar day, so the layer answers it as
2
+ // `YYYY-MM-DD` text. A selected `date` column is read as
3
+ // the database's own text (`cast(<column> as text)`, `nodes.ts`), never through the `Date` `Bun.SQL` decodes: outside
4
+ // 0001–9999 that one drifts by the process's offset and can land on a neighbouring valid day. Only an aggregate's
5
+ // `min`/`max` and a store's joined child column still arrive as a `Date`. The generated column list names the `date`
6
+ // columns (`TableColumns.dates`); every read of a table's rows passes through `answerDays`. A write's text for a day or a
7
+ // timestamp is checked here before anything is sent: Postgres would also take `now`, `today` or `10/12/2026`, and read
8
+ // a timestamp without an offset in the session's time zone.
9
+
10
+ import { defineValue, inSet, type NameSet, ownDescriptor, ownKeys } from './builtins'
11
+ import { QueryRefusedError } from './errors'
12
+
13
+ /** The text Postgres prints for a day in 0001–9999 under its default `DateStyle` (ISO). */
14
+ const DAY_TEXT = /^[0-9]{4}-[0-9]{2}-[0-9]{2}$/
15
+
16
+ const MS_PER_DAY = 86_400_000
17
+
18
+ function refused(where: string): QueryRefusedError {
19
+ return new QueryRefusedError('invalid_value', `${where} holds a date outside 0001-01-01 to 9999-12-31, which the layer cannot read as YYYY-MM-DD`)
20
+ }
21
+
22
+ /**
23
+ * A day as the layer answers it: the database's `YYYY-MM-DD` text, or a decoded `Date` that is exactly a UTC midnight
24
+ * in 0001–9999 — the only `Date` `Bun.SQL` decodes correctly from a `date`, whatever the process's time zone.
25
+ */
26
+ export function dayText(value: string | Date, where: string): string {
27
+ if (typeof value === 'string') {
28
+ if (!DAY_TEXT.test(value)) {
29
+ throw refused(where)
30
+ }
31
+ return value
32
+ }
33
+ const time = value.getTime()
34
+ const year = value.getUTCFullYear()
35
+ if (Number.isNaN(time) || time % MS_PER_DAY !== 0 || year < 1 || year > 9999) {
36
+ throw refused(where)
37
+ }
38
+ return value.toISOString().slice(0, 10)
39
+ }
40
+
41
+ /** Replaces each value under a key of `days` in every row with its `YYYY-MM-DD` text, in place. */
42
+ export function answerDays<R>(table: string, days: NameSet, rows: R[]): R[] {
43
+ if (ownKeys(days).length === 0) {
44
+ return rows
45
+ }
46
+ for (let index = 0; index < rows.length; index++) {
47
+ const row = rows[index]
48
+ if (typeof row !== 'object' || row === null) {
49
+ continue
50
+ }
51
+ const keys = ownKeys(row)
52
+ for (let position = 0; position < keys.length; position++) {
53
+ const key = keys[position] ?? ''
54
+ const value: unknown = ownDescriptor(row, key)?.value
55
+ if (inSet(days, key) && (typeof value === 'string' || value instanceof Date)) {
56
+ defineValue(row, key, dayText(value, `${table}.${key}`))
57
+ }
58
+ }
59
+ }
60
+ return rows
61
+ }
62
+
63
+ /** An instant in strict ISO 8601: a day, `T`, the time to the minute or finer, and `Z` or an offset. */
64
+ const TIMESTAMP_TEXT = /^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2})(?::([0-9]{2})(?:\.[0-9]{1,6})?)?(?:Z|[+-]([0-9]{2}):([0-9]{2}))$/
65
+
66
+ /** Whether `year-month-day` is a day of the calendar: no 30 February, no month 13. */
67
+ function isCalendarDay(year: number, month: number, day: number): boolean {
68
+ const date = new Date(Date.UTC(year, month - 1, day))
69
+ return year >= 1 && date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day
70
+ }
71
+
72
+ /** A day a write binds: `YYYY-MM-DD` text of a real day, as the layer answers one. */
73
+ export function dayInput(value: string, where: string): string {
74
+ const [year, month, day] = value.split('-').map(Number)
75
+ if (!DAY_TEXT.test(value) || year === undefined || month === undefined || day === undefined || !isCalendarDay(year, month, day)) {
76
+ throw new QueryRefusedError('invalid_value', `${where} takes a day as YYYY-MM-DD text, such as 2026-10-12`)
77
+ }
78
+ return value
79
+ }
80
+
81
+ /** A timestamp a write binds as text: strict ISO 8601 with `Z` or an offset, every field in range. */
82
+ export function timestampInput(value: string, where: string): string {
83
+ const match = TIMESTAMP_TEXT.exec(value)
84
+ const [, year, month, day, hour, minute, second = '0', offsetHour = '0', offsetMinute = '0'] = match ?? []
85
+ const valid = match !== null && isCalendarDay(Number(year), Number(month), Number(day)) && Number(hour) < 24 && Number(minute) < 60
86
+ && Number(second) < 60 && Number(offsetHour) < 16 && Number(offsetMinute) < 60
87
+ if (!valid) {
88
+ throw new QueryRefusedError('invalid_value', `${where} takes a Date or ISO 8601 text with Z or an offset, such as 2026-10-12T08:30:00.000Z`)
89
+ }
90
+ return value
91
+ }