nucleus-core-ts 0.9.779 → 0.9.780
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nucleus-core-ts",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.780",
|
|
4
4
|
"description": "Production-ready, enterprise-grade TypeScript framework for building multi-tenant APIs",
|
|
5
5
|
"author": "Hidayet Can Özcan <hidayetcan@gmail.com>",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE",
|
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
"infra/templates",
|
|
20
20
|
"infra/scripts/generate-project.ts",
|
|
21
21
|
"src/system.tables.json",
|
|
22
|
+
"src/Services/SchemaTables/resolveEntities.ts",
|
|
23
|
+
"src/Services/Logger/auditTaxonomy.ts",
|
|
22
24
|
"public",
|
|
23
25
|
"LICENSE"
|
|
24
26
|
],
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
3
|
+
import { dirname, join, relative, resolve } from 'node:path'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Guard: everything `scripts/` imports must actually be IN the npm tarball.
|
|
7
|
+
*
|
|
8
|
+
* The scripts in this package are shipped as raw TypeScript and executed inside a
|
|
9
|
+
* consumer's node_modules (`bunx nucleus-generate` runs scripts/generate-schema.ts
|
|
10
|
+
* during their Docker build). `package.json#files` ships `scripts` and `dist` but
|
|
11
|
+
* NOT `src` — so a relative import that reaches into src/ resolves fine in this
|
|
12
|
+
* repo and fails in every consumer with
|
|
13
|
+
*
|
|
14
|
+
* Cannot find module '../src/…' from '/app/node_modules/nucleus-core-ts/scripts/…'
|
|
15
|
+
*
|
|
16
|
+
* That shipped in 0.9.779 and broke the Docker build of every app that generates
|
|
17
|
+
* its schema at build time. Local tests could not see it: the failure only exists
|
|
18
|
+
* in the packaged layout.
|
|
19
|
+
*/
|
|
20
|
+
const ROOT = join(import.meta.dir, '..')
|
|
21
|
+
const FILES: string[] = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8')).files
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Scripts only ever run from THIS repo by a maintainer (release tooling). They may
|
|
25
|
+
* read repo-only files like nucleus.config.ts, which is deliberately not shipped.
|
|
26
|
+
*/
|
|
27
|
+
const MAINTAINER_ONLY = new Set(['build.ts', 'publish.ts', 'version.ts'])
|
|
28
|
+
|
|
29
|
+
/** True when `relPath` (repo-relative) is covered by a package.json#files entry. */
|
|
30
|
+
function isShipped(relPath: string): boolean {
|
|
31
|
+
return FILES.some((entry) => relPath === entry || relPath.startsWith(`${entry}/`))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function scriptFiles(): string[] {
|
|
35
|
+
return readdirSync(join(ROOT, 'scripts'))
|
|
36
|
+
.filter((f) => f.endsWith('.ts') && !f.endsWith('.test.ts') && !MAINTAINER_ONLY.has(f))
|
|
37
|
+
.map((f) => join(ROOT, 'scripts', f))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function relativeImports(file: string): string[] {
|
|
41
|
+
const src = readFileSync(file, 'utf-8')
|
|
42
|
+
const out: string[] = []
|
|
43
|
+
for (const m of src.matchAll(/(?:from|import)\s+['"](\.[^'"]+)['"]/g)) {
|
|
44
|
+
const spec = m[1]
|
|
45
|
+
if (spec) out.push(spec)
|
|
46
|
+
}
|
|
47
|
+
return out
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Resolve an extensionless TS import the way Bun does. */
|
|
51
|
+
function resolveImport(fromFile: string, spec: string): string | null {
|
|
52
|
+
const base = resolve(dirname(fromFile), spec)
|
|
53
|
+
for (const candidate of [base, `${base}.ts`, `${base}.json`, join(base, 'index.ts')]) {
|
|
54
|
+
if (existsSync(candidate)) return candidate
|
|
55
|
+
}
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe('shipped scripts only import shipped files', () => {
|
|
60
|
+
test('package.json#files does not include the whole src directory', () => {
|
|
61
|
+
// If this ever changes the guard below becomes vacuous — fail loudly instead.
|
|
62
|
+
expect(FILES).not.toContain('src')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('every relative import of every shipped script is in the tarball', () => {
|
|
66
|
+
const missing: string[] = []
|
|
67
|
+
|
|
68
|
+
for (const file of scriptFiles()) {
|
|
69
|
+
for (const spec of relativeImports(file)) {
|
|
70
|
+
const target = resolveImport(file, spec)
|
|
71
|
+
// Unresolvable specifiers come from the code these scripts EMIT (the schema
|
|
72
|
+
// generator writes `import … from './schema'` into its output), not from the
|
|
73
|
+
// script's own imports. Only real imports matter here.
|
|
74
|
+
if (!target) continue
|
|
75
|
+
const rel = relative(ROOT, target)
|
|
76
|
+
if (!isShipped(rel)) {
|
|
77
|
+
missing.push(`${relative(ROOT, file)} -> ${rel} (not in package.json#files)`)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
expect(missing).toEqual([])
|
|
83
|
+
})
|
|
84
|
+
})
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audit taxonomy — the single source of truth for classifying audit_logs rows by
|
|
3
|
+
* security severity and category, and for deciding which routine outcomes are
|
|
4
|
+
* noise that should NOT be persisted by default.
|
|
5
|
+
*
|
|
6
|
+
* Why this exists: the auth middleware writes an audit row on every rejected
|
|
7
|
+
* request. A logged-out browser tab polling an authenticated endpoint (e.g.
|
|
8
|
+
* `/auth/me`) therefore produces one "No session token" row per poll — an HTTP
|
|
9
|
+
* outcome with no security signal — which numerically swamps the real events
|
|
10
|
+
* (logins, CRUD, admin actions). Classifying every row by severity/category and
|
|
11
|
+
* suppressing the routine credential-absence reasons by default keeps the
|
|
12
|
+
* Security Logs view high-signal while preserving every genuine security event.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type AuditSeverity = 'info' | 'low' | 'medium' | 'high' | 'critical'
|
|
16
|
+
|
|
17
|
+
export type AuditCategory =
|
|
18
|
+
| 'auth' // authentication outcomes (login/logout/register, token/session checks)
|
|
19
|
+
| 'authz' // authorization outcomes (claims/cohort/role denials)
|
|
20
|
+
| 'data_change' // entity CREATE/UPDATE/PATCH/DELETE
|
|
21
|
+
| 'admin' // privileged operations (impersonate, hard-delete, change-user-id, provision)
|
|
22
|
+
| 'config' // server/configuration problems surfaced on a request
|
|
23
|
+
| 'security_anomaly' // suspicious: forged/tampered token, tenant mismatch, revoked-token reuse
|
|
24
|
+
| 'system' // internal/system-originated
|
|
25
|
+
|
|
26
|
+
/** Numeric ordering so a configured `minSeverity` can drop everything below it. */
|
|
27
|
+
export const AUDIT_SEVERITY_RANK: Record<AuditSeverity, number> = {
|
|
28
|
+
info: 0,
|
|
29
|
+
low: 1,
|
|
30
|
+
medium: 2,
|
|
31
|
+
high: 3,
|
|
32
|
+
critical: 4,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function severityRank(severity: AuditSeverity | undefined): number {
|
|
36
|
+
return AUDIT_SEVERITY_RANK[severity ?? 'info'] ?? 0
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Routine, NON-security auth outcomes — the steady state of any unauthenticated
|
|
41
|
+
* or stale client. These carry no actor identity and no attack signal, so they
|
|
42
|
+
* are suppressed from audit_logs by default (still emitted at debug level). The
|
|
43
|
+
* strings MUST match the `summary` passed at the middleware reject sites in
|
|
44
|
+
* `src/ElysiaPlugin/index.ts`.
|
|
45
|
+
*/
|
|
46
|
+
export const ROUTINE_AUTH_REASONS = [
|
|
47
|
+
'No session token',
|
|
48
|
+
'Invalid session',
|
|
49
|
+
'Session expired',
|
|
50
|
+
'Session inactive timeout',
|
|
51
|
+
'Authentication secrets not defined',
|
|
52
|
+
] as const
|
|
53
|
+
|
|
54
|
+
/** Default value for `audit.suppressReasons` — set to `[]` to capture everything (legacy). */
|
|
55
|
+
export const DEFAULT_AUDIT_SUPPRESS_REASONS: string[] = [...ROUTINE_AUTH_REASONS]
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Explicit severity/category for each known auth-middleware reject reason. The
|
|
59
|
+
* middleware passes these via `toAudit(...)` so they don't depend on string
|
|
60
|
+
* re-classification. Reasons absent here fall back to {@link classifyAudit}.
|
|
61
|
+
*/
|
|
62
|
+
export const AUTH_FAILURE_CLASSIFICATION: Record<
|
|
63
|
+
string,
|
|
64
|
+
{ severity: AuditSeverity; category: AuditCategory }
|
|
65
|
+
> = {
|
|
66
|
+
'No session token': { severity: 'low', category: 'auth' },
|
|
67
|
+
'Invalid session': { severity: 'low', category: 'auth' },
|
|
68
|
+
'Session expired': { severity: 'low', category: 'auth' },
|
|
69
|
+
'Session inactive timeout': { severity: 'low', category: 'auth' },
|
|
70
|
+
'Authentication secrets not defined': { severity: 'critical', category: 'config' },
|
|
71
|
+
'Session revoked': { severity: 'high', category: 'security_anomaly' },
|
|
72
|
+
'Invalid or missing access token': { severity: 'medium', category: 'auth' },
|
|
73
|
+
'Access token tenant binding failed': { severity: 'high', category: 'security_anomaly' },
|
|
74
|
+
'Invalid API key': { severity: 'medium', category: 'auth' },
|
|
75
|
+
'Cohort expired': { severity: 'medium', category: 'authz' },
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Derive a sensible default severity/category from an audit `operation` (and
|
|
80
|
+
* optional `summary`) when the call site did not provide them explicitly. This
|
|
81
|
+
* lets the ~20 route audit sites get correct classification for free, while the
|
|
82
|
+
* middleware still passes explicit tags via {@link AUTH_FAILURE_CLASSIFICATION}.
|
|
83
|
+
*/
|
|
84
|
+
export function classifyAudit(
|
|
85
|
+
operation: string,
|
|
86
|
+
summary?: string
|
|
87
|
+
): { severity: AuditSeverity; category: AuditCategory } {
|
|
88
|
+
const op = (operation || '').toUpperCase()
|
|
89
|
+
|
|
90
|
+
// Dynamic API-key rejection ("API key rejected: <reason>").
|
|
91
|
+
if (summary?.startsWith('API key rejected')) {
|
|
92
|
+
return { severity: 'medium', category: 'auth' }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Known auth-failure reasons (also covers middleware sites that pass operation
|
|
96
|
+
// = HTTP method but a recognizable summary).
|
|
97
|
+
if (summary && AUTH_FAILURE_CLASSIFICATION[summary]) {
|
|
98
|
+
return AUTH_FAILURE_CLASSIFICATION[summary]
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Privileged/admin operations. IMPERSONATE is matched by prefix because the
|
|
102
|
+
// routes write IMPERSONATE_START / IMPERSONATE_STOP (never a bare 'IMPERSONATE') —
|
|
103
|
+
// an exact match here silently dropped impersonation to info/data_change.
|
|
104
|
+
if (
|
|
105
|
+
op.startsWith('IMPERSONATE') ||
|
|
106
|
+
op === 'HARD_DELETE' ||
|
|
107
|
+
op === 'CHANGE_USER_ID' ||
|
|
108
|
+
op === 'PROVISION' ||
|
|
109
|
+
op === 'RESTORE' ||
|
|
110
|
+
op.startsWith('ADMIN_')
|
|
111
|
+
) {
|
|
112
|
+
return { severity: 'high', category: 'admin' }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Authentication lifecycle.
|
|
116
|
+
if (op.endsWith('_FAILED')) {
|
|
117
|
+
return { severity: 'medium', category: 'auth' }
|
|
118
|
+
}
|
|
119
|
+
if (op.startsWith('PASSWORD')) {
|
|
120
|
+
return { severity: 'medium', category: 'auth' }
|
|
121
|
+
}
|
|
122
|
+
if (
|
|
123
|
+
op === 'LOGIN' ||
|
|
124
|
+
op === 'LOGOUT' ||
|
|
125
|
+
op === 'REGISTER' ||
|
|
126
|
+
op === 'OAUTH_LOGIN' ||
|
|
127
|
+
op === 'MAGIC_LINK' ||
|
|
128
|
+
op === 'INVITE'
|
|
129
|
+
) {
|
|
130
|
+
return { severity: 'info', category: 'auth' }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Entity data changes.
|
|
134
|
+
if (
|
|
135
|
+
op === 'CREATE' ||
|
|
136
|
+
op === 'UPDATE' ||
|
|
137
|
+
op === 'PATCH' ||
|
|
138
|
+
op === 'DELETE' ||
|
|
139
|
+
op === 'TOGGLE' ||
|
|
140
|
+
op === 'VERIFICATION'
|
|
141
|
+
) {
|
|
142
|
+
return { severity: 'info', category: 'data_change' }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Raw HTTP method with no recognized summary => an untagged middleware reject.
|
|
146
|
+
if (op === 'GET' || op === 'POST' || op === 'PUT' || op === 'PATCH' || op === 'HEAD') {
|
|
147
|
+
return { severity: 'low', category: 'auth' }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return { severity: 'info', category: 'data_change' }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Label to show for an audit row that has no resolved actor. */
|
|
154
|
+
export const ANONYMOUS_ACTOR_LABEL = 'Anonymous'
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single source of truth for "what tables does this app actually have".
|
|
3
|
+
*
|
|
4
|
+
* Three consumers used to answer this question independently — schema generation
|
|
5
|
+
* (scripts/generate-schema.ts), entity route registration
|
|
6
|
+
* (ElysiaPlugin/routes/entity/index.ts) and claim seeding (ElysiaPlugin/index.ts,
|
|
7
|
+
* three call sites) — and they disagreed: the boot/main-schema seeding site let the
|
|
8
|
+
* BUILT-IN columns win while the tenant sites let the CONFIG columns win, so the
|
|
9
|
+
* same config produced different claims in the main schema and in a tenant schema.
|
|
10
|
+
* This function replaces all of that with one rule.
|
|
11
|
+
*
|
|
12
|
+
* PURE: no I/O, no logger, no process access. Errors and warnings are RETURNED, so
|
|
13
|
+
* the caller decides whether to fail boot (runtime) or print and exit (script).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Minimal structural shape this module reads. Arrays are `readonly` so that both
|
|
18
|
+
* the deeply-`as const` SYSTEM_TABLES tuple and a mutable NucleusTable[] satisfy it.
|
|
19
|
+
*/
|
|
20
|
+
export interface ExtendableColumnLike {
|
|
21
|
+
name: string
|
|
22
|
+
notNull?: boolean
|
|
23
|
+
nullable?: boolean
|
|
24
|
+
default?: unknown
|
|
25
|
+
defaultRaw?: string
|
|
26
|
+
generatedAlwaysAs?: string
|
|
27
|
+
generatedAlwaysAsIdentity?: boolean
|
|
28
|
+
generatedByDefaultAsIdentity?: boolean
|
|
29
|
+
sensitive?: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ExtendableTableLike {
|
|
33
|
+
table_name: string
|
|
34
|
+
extends?: boolean
|
|
35
|
+
add_base_columns?: boolean
|
|
36
|
+
feature_set?: readonly string[]
|
|
37
|
+
columns?: readonly ExtendableColumnLike[]
|
|
38
|
+
indexes?: readonly unknown[]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type ExtendErrorCode =
|
|
42
|
+
| 'E1_COLUMN_COLLISION'
|
|
43
|
+
| 'E2_NOT_NULL_WITHOUT_DEFAULT'
|
|
44
|
+
| 'E3_CONSUMER_AUTH_TABLE'
|
|
45
|
+
| 'E4_UNKNOWN_SYSTEM_TABLE'
|
|
46
|
+
| 'E5_FEATURE_DISABLED'
|
|
47
|
+
|
|
48
|
+
export interface ExtendError {
|
|
49
|
+
code: ExtendErrorCode
|
|
50
|
+
table: string
|
|
51
|
+
column?: string
|
|
52
|
+
message: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface ResolveEntitiesOptions {
|
|
56
|
+
/** Base column names added when add_base_columns is not false. Enables the base half of E1. */
|
|
57
|
+
baseColumnNames?: readonly string[]
|
|
58
|
+
/** True when authentication.mode === 'consumer'. Enables E3. */
|
|
59
|
+
consumerMode?: boolean
|
|
60
|
+
/** System table names that survived feature gating. When given, enables E5. */
|
|
61
|
+
enabledTableNames?: ReadonlySet<string>
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface ResolveEntitiesResult<T> {
|
|
65
|
+
entities: T[]
|
|
66
|
+
errors: ExtendError[]
|
|
67
|
+
warnings: string[]
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Fields an extending entry may declare. Anything else is inherited and warned about. */
|
|
71
|
+
const EXTEND_ALLOWED_KEYS = new Set(['table_name', 'extends', 'columns', 'indexes'])
|
|
72
|
+
|
|
73
|
+
/** A column can satisfy notNull without a client-supplied value only via a DB-side default. */
|
|
74
|
+
function hasDbDefault(column: ExtendableColumnLike): boolean {
|
|
75
|
+
return (
|
|
76
|
+
column.default !== undefined ||
|
|
77
|
+
column.defaultRaw !== undefined ||
|
|
78
|
+
column.generatedAlwaysAs !== undefined ||
|
|
79
|
+
column.generatedAlwaysAsIdentity === true ||
|
|
80
|
+
column.generatedByDefaultAsIdentity === true
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const AUTH_FEATURES = new Set(['authentication', 'authorization'])
|
|
85
|
+
|
|
86
|
+
export function resolveEntities<T extends ExtendableTableLike>(
|
|
87
|
+
systemTables: readonly T[],
|
|
88
|
+
configEntities: readonly T[],
|
|
89
|
+
options: ResolveEntitiesOptions = {}
|
|
90
|
+
): ResolveEntitiesResult<T> {
|
|
91
|
+
const errors: ExtendError[] = []
|
|
92
|
+
const warnings: string[] = []
|
|
93
|
+
const consumed = new Set<string>()
|
|
94
|
+
const entities: T[] = []
|
|
95
|
+
|
|
96
|
+
for (const system of systemTables) {
|
|
97
|
+
const config = configEntities.find((c) => c.table_name === system.table_name)
|
|
98
|
+
if (!config) {
|
|
99
|
+
entities.push(system)
|
|
100
|
+
continue
|
|
101
|
+
}
|
|
102
|
+
consumed.add(config.table_name)
|
|
103
|
+
|
|
104
|
+
if (!config.extends) {
|
|
105
|
+
warnings.push(
|
|
106
|
+
`[Schema] "${config.table_name}" is declared in config.entities AND ships as a system table. ` +
|
|
107
|
+
'Using YOUR definition and skipping the built-in one. Add "extends": true to inherit the ' +
|
|
108
|
+
'built-in columns and route policy instead.'
|
|
109
|
+
)
|
|
110
|
+
entities.push(config)
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
for (const key of Object.keys(config)) {
|
|
115
|
+
if (!EXTEND_ALLOWED_KEYS.has(key)) {
|
|
116
|
+
warnings.push(
|
|
117
|
+
`[Schema] "${config.table_name}" sets "${key}" alongside "extends": true — ignored. ` +
|
|
118
|
+
'An extending entry inherits every table-level setting from the built-in definition.'
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (options.enabledTableNames && !options.enabledTableNames.has(system.table_name)) {
|
|
124
|
+
errors.push({
|
|
125
|
+
code: 'E5_FEATURE_DISABLED',
|
|
126
|
+
table: system.table_name,
|
|
127
|
+
message:
|
|
128
|
+
`"${system.table_name}" is extended but its feature is disabled, so the table is never ` +
|
|
129
|
+
'created. Enable the feature or remove the extending entry.',
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (options.consumerMode && (system.feature_set ?? []).some((f) => AUTH_FEATURES.has(f))) {
|
|
134
|
+
errors.push({
|
|
135
|
+
code: 'E3_CONSUMER_AUTH_TABLE',
|
|
136
|
+
table: system.table_name,
|
|
137
|
+
message:
|
|
138
|
+
`"${system.table_name}" is an auth system table and cannot be extended in consumer mode — ` +
|
|
139
|
+
'the identity tables live in the IDP. Extending it would create a local copy here.',
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const builtInNames = new Set((system.columns ?? []).map((c) => c.name))
|
|
144
|
+
if (system.add_base_columns !== false) {
|
|
145
|
+
for (const name of options.baseColumnNames ?? []) builtInNames.add(name)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
for (const column of config.columns ?? []) {
|
|
149
|
+
if (builtInNames.has(column.name)) {
|
|
150
|
+
errors.push({
|
|
151
|
+
code: 'E1_COLUMN_COLLISION',
|
|
152
|
+
table: system.table_name,
|
|
153
|
+
column: column.name,
|
|
154
|
+
message:
|
|
155
|
+
`"${system.table_name}.${column.name}" already exists in the built-in definition. ` +
|
|
156
|
+
'An extending entry may only ADD columns; rename yours.',
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
if (column.notNull === true && column.nullable !== true && !hasDbDefault(column)) {
|
|
160
|
+
errors.push({
|
|
161
|
+
code: 'E2_NOT_NULL_WITHOUT_DEFAULT',
|
|
162
|
+
table: system.table_name,
|
|
163
|
+
column: column.name,
|
|
164
|
+
message:
|
|
165
|
+
`"${system.table_name}.${column.name}" is notNull without a default. The framework writes ` +
|
|
166
|
+
'this table from fixed-column inserts (register, invite, admin create-user, godmin seed, ' +
|
|
167
|
+
'tenant provisioning) that cannot supply it — every one of them would fail. Make it ' +
|
|
168
|
+
'nullable or give it a default.',
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const merged = {
|
|
174
|
+
...system,
|
|
175
|
+
columns: [...(system.columns ?? []), ...(config.columns ?? [])],
|
|
176
|
+
indexes: [...(system.indexes ?? []), ...(config.indexes ?? [])],
|
|
177
|
+
} as unknown as T
|
|
178
|
+
entities.push(merged)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
for (const config of configEntities) {
|
|
182
|
+
if (consumed.has(config.table_name)) continue
|
|
183
|
+
if (config.extends) {
|
|
184
|
+
errors.push({
|
|
185
|
+
code: 'E4_UNKNOWN_SYSTEM_TABLE',
|
|
186
|
+
table: config.table_name,
|
|
187
|
+
message:
|
|
188
|
+
`"${config.table_name}" sets "extends": true but there is no system table with that name. ` +
|
|
189
|
+
'Remove the flag to declare it as an ordinary entity.',
|
|
190
|
+
})
|
|
191
|
+
continue
|
|
192
|
+
}
|
|
193
|
+
entities.push(config)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return { entities, errors, warnings }
|
|
197
|
+
}
|