@exvio/os-backend-core 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.
@@ -0,0 +1,148 @@
1
+ import { getCurrentTenant } from '../db/tenant-context.ts'
2
+ import { ChangelogError } from './types.ts'
3
+ import type {
4
+ ChangelogAcknowledgementInput,
5
+ ChangelogAcknowledgementResult,
6
+ ChangelogService,
7
+ ChangelogServiceOptions,
8
+ ChangelogSnapshot,
9
+ ChangelogStateInput,
10
+ ChangelogSubject,
11
+ ChangelogSubjectId,
12
+ } from './types.ts'
13
+
14
+ export function createChangelogService<Locale extends string, SubjectId extends ChangelogSubjectId>(
15
+ options: ChangelogServiceOptions<Locale, SubjectId>,
16
+ ): ChangelogService<Locale, SubjectId> {
17
+ if (!options?.catalogue || !options.acknowledgements) {
18
+ throw new ChangelogError({
19
+ code: 'invalid_catalogue',
20
+ message: 'Changelog service configuration is invalid',
21
+ })
22
+ }
23
+ const tenantResolver = options.tenantResolver ?? (() => {
24
+ const store = getCurrentTenant()
25
+ return store && !store.bypass ? store.tenantId : undefined
26
+ })
27
+
28
+ const subjectFor = (subjectId: SubjectId, explicitTenantId?: number): ChangelogSubject<SubjectId> => {
29
+ let tenantId: number
30
+ if (explicitTenantId !== undefined) {
31
+ validateTenant(explicitTenantId)
32
+ const activeTenantId = resolveOptionalTenant(tenantResolver)
33
+ if (activeTenantId !== undefined) {
34
+ validateTenant(activeTenantId)
35
+ if (activeTenantId !== explicitTenantId) {
36
+ throw new ChangelogError({
37
+ code: 'invalid_tenant',
38
+ message: 'Explicit changelog tenant does not match the active tenant',
39
+ })
40
+ }
41
+ }
42
+ tenantId = explicitTenantId
43
+ } else {
44
+ tenantId = resolveTenant(tenantResolver)
45
+ }
46
+ validateTenant(tenantId)
47
+ validateSubject(subjectId)
48
+ return Object.freeze({ tenantId, subjectId })
49
+ }
50
+
51
+ const getState = async (
52
+ input: ChangelogStateInput<Locale, SubjectId>,
53
+ ): Promise<ChangelogSnapshot<Locale>> => {
54
+ if (!input || typeof input !== 'object') {
55
+ throw new ChangelogError({ code: 'invalid_subject', message: 'Changelog subject is invalid' })
56
+ }
57
+ const subject = subjectFor(input.subjectId, input.tenantId)
58
+ const locale = input.locale ?? options.catalogue.resolveLocale(input.acceptLanguage)
59
+ if (!options.catalogue.isLocale(locale)) {
60
+ throw new ChangelogError({ code: 'invalid_locale', message: 'Changelog locale is invalid' })
61
+ }
62
+ let seenVersion: string | null
63
+ try {
64
+ seenVersion = await options.acknowledgements.getSeenVersion(subject)
65
+ } catch {
66
+ throw storeFailure()
67
+ }
68
+ if (seenVersion !== null && typeof seenVersion !== 'string') throw storeFailure()
69
+
70
+ return Object.freeze({
71
+ version: options.catalogue.latest.version,
72
+ seen: seenVersion === options.catalogue.latest.version,
73
+ seenVersion,
74
+ locale,
75
+ entries: options.catalogue.localize(locale),
76
+ })
77
+ }
78
+
79
+ const acknowledge = async (
80
+ input: ChangelogAcknowledgementInput<SubjectId>,
81
+ ): Promise<ChangelogAcknowledgementResult> => {
82
+ if (!input || typeof input !== 'object') {
83
+ return Object.freeze({ ok: false, reason: 'invalid_version' })
84
+ }
85
+ const subject = subjectFor(input.subjectId, input.tenantId)
86
+ const version = typeof input.version === 'string' ? input.version.trim() : ''
87
+ if (
88
+ !version
89
+ || version !== input.version
90
+ || version.length > options.catalogue.maxVersionLength
91
+ ) return Object.freeze({ ok: false, reason: 'invalid_version' })
92
+ if (!options.catalogue.isKnownVersion(version)) {
93
+ return Object.freeze({ ok: false, reason: 'unknown_version' })
94
+ }
95
+ try {
96
+ await options.acknowledgements.acknowledge(subject, version)
97
+ } catch {
98
+ throw storeFailure()
99
+ }
100
+ return Object.freeze({ ok: true, version })
101
+ }
102
+
103
+ return Object.freeze({ getState, acknowledge })
104
+ }
105
+
106
+ function resolveTenant(resolver: () => number | null | undefined): number {
107
+ const tenantId = resolveOptionalTenant(resolver)
108
+ if (tenantId === undefined) {
109
+ throw new ChangelogError({ code: 'missing_tenant', message: 'Changelog tenant context is required' })
110
+ }
111
+ return tenantId
112
+ }
113
+
114
+ function resolveOptionalTenant(resolver: () => number | null | undefined): number | undefined {
115
+ let tenantId: number | null | undefined
116
+ try {
117
+ tenantId = resolver()
118
+ } catch {
119
+ throw new ChangelogError({ code: 'missing_tenant', message: 'Changelog tenant context is unavailable' })
120
+ }
121
+ return tenantId ?? undefined
122
+ }
123
+
124
+ function validateTenant(tenantId: number): void {
125
+ if (!Number.isSafeInteger(tenantId) || tenantId <= 0) {
126
+ throw new ChangelogError({ code: 'invalid_tenant', message: 'Changelog tenant is invalid' })
127
+ }
128
+ }
129
+
130
+ function validateSubject(subjectId: ChangelogSubjectId): void {
131
+ const valid = typeof subjectId === 'number'
132
+ ? Number.isSafeInteger(subjectId) && subjectId > 0
133
+ : typeof subjectId === 'string'
134
+ ? subjectId.length > 0
135
+ && subjectId.length <= 256
136
+ && subjectId.trim() === subjectId
137
+ : false
138
+ if (!valid) {
139
+ throw new ChangelogError({ code: 'invalid_subject', message: 'Changelog subject is invalid' })
140
+ }
141
+ }
142
+
143
+ function storeFailure(): ChangelogError {
144
+ return new ChangelogError({
145
+ code: 'store_failure',
146
+ message: 'Changelog acknowledgement store failed',
147
+ })
148
+ }
@@ -0,0 +1,122 @@
1
+ export type ChangelogSubjectId = string | number
2
+
3
+ export interface ChangelogEntry<Locale extends string> {
4
+ readonly version: string
5
+ readonly date: string
6
+ readonly title: string
7
+ /** The default locale is required; other locales may fall back to it. */
8
+ readonly body: Readonly<Partial<Record<Locale, string>>>
9
+ }
10
+
11
+ export interface LocalizedChangelogEntry<Locale extends string> {
12
+ readonly version: string
13
+ readonly date: string
14
+ readonly title: string
15
+ readonly body: string
16
+ /** Actual body locale after per-entry fallback. */
17
+ readonly locale: Locale
18
+ }
19
+
20
+ export interface ChangelogSubject<SubjectId extends ChangelogSubjectId = ChangelogSubjectId> {
21
+ readonly tenantId: number
22
+ readonly subjectId: SubjectId
23
+ }
24
+
25
+ export interface ChangelogSnapshot<Locale extends string> {
26
+ readonly version: string
27
+ readonly seen: boolean
28
+ readonly seenVersion: string | null
29
+ /** Requested/negotiated response locale. Entries report their own fallback locale. */
30
+ readonly locale: Locale
31
+ readonly entries: readonly LocalizedChangelogEntry<Locale>[]
32
+ }
33
+
34
+ export interface ChangelogAcknowledgementStore<SubjectId extends ChangelogSubjectId> {
35
+ getSeenVersion(subject: ChangelogSubject<SubjectId>): Promise<string | null>
36
+ acknowledge(subject: ChangelogSubject<SubjectId>, version: string): Promise<void>
37
+ }
38
+
39
+ export type ChangelogAcknowledgementResult =
40
+ | { readonly ok: true; readonly version: string }
41
+ | { readonly ok: false; readonly reason: 'invalid_version' | 'unknown_version' }
42
+
43
+ export type ChangelogErrorCode =
44
+ | 'invalid_catalogue'
45
+ | 'invalid_locale'
46
+ | 'invalid_subject'
47
+ | 'missing_tenant'
48
+ | 'invalid_tenant'
49
+ | 'store_failure'
50
+
51
+ export interface ChangelogErrorOptions {
52
+ readonly code: ChangelogErrorCode
53
+ readonly message: string
54
+ }
55
+
56
+ /** Stable error envelope; storage/config causes are deliberately not retained. */
57
+ export class ChangelogError extends Error {
58
+ readonly code: ChangelogErrorCode
59
+
60
+ constructor(options: ChangelogErrorOptions) {
61
+ super(options.message)
62
+ this.name = 'ChangelogError'
63
+ this.code = options.code
64
+ }
65
+ }
66
+
67
+ export interface AcceptLanguageResolverOptions<Locale extends string> {
68
+ readonly locales: readonly Locale[]
69
+ readonly defaultLocale: Locale
70
+ /** Lower/upper-case BCP-47 tags are normalized; values are internal locale keys. */
71
+ readonly languageTags?: Readonly<Record<string, Locale>>
72
+ }
73
+
74
+ export type AcceptLanguageResolver<Locale extends string> = (
75
+ acceptLanguage?: string | null,
76
+ ) => Locale
77
+
78
+ export interface ChangelogCatalogueOptions<Locale extends string>
79
+ extends AcceptLanguageResolverOptions<Locale> {
80
+ readonly entries: readonly ChangelogEntry<Locale>[]
81
+ /** Must match the consumer acknowledgement column width. */
82
+ readonly maxVersionLength: number
83
+ }
84
+
85
+ export interface ChangelogCatalogue<Locale extends string> {
86
+ readonly entries: readonly ChangelogEntry<Locale>[]
87
+ readonly latest: ChangelogEntry<Locale>
88
+ readonly locales: readonly Locale[]
89
+ readonly defaultLocale: Locale
90
+ readonly maxVersionLength: number
91
+ resolveLocale(acceptLanguage?: string | null): Locale
92
+ isLocale(value: unknown): value is Locale
93
+ isKnownVersion(version: string): boolean
94
+ bodyFor(entry: ChangelogEntry<Locale>, locale: Locale): { body: string; locale: Locale }
95
+ localize(locale: Locale): readonly LocalizedChangelogEntry<Locale>[]
96
+ }
97
+
98
+ export interface ChangelogServiceOptions<Locale extends string, SubjectId extends ChangelogSubjectId> {
99
+ readonly catalogue: ChangelogCatalogue<Locale>
100
+ readonly acknowledgements: ChangelogAcknowledgementStore<SubjectId>
101
+ /** Defaults to the backend-core tenant ALS and ignores bypass scopes. */
102
+ readonly tenantResolver?: () => number | null | undefined
103
+ }
104
+
105
+ export interface ChangelogStateInput<Locale extends string, SubjectId extends ChangelogSubjectId> {
106
+ readonly subjectId: SubjectId
107
+ readonly tenantId?: number
108
+ readonly locale?: Locale
109
+ readonly acceptLanguage?: string | null
110
+ }
111
+
112
+ export interface ChangelogAcknowledgementInput<SubjectId extends ChangelogSubjectId> {
113
+ readonly subjectId: SubjectId
114
+ readonly tenantId?: number
115
+ /** Runtime input is unknown so HTTP adapters can pass decoded JSON directly. */
116
+ readonly version: unknown
117
+ }
118
+
119
+ export interface ChangelogService<Locale extends string, SubjectId extends ChangelogSubjectId> {
120
+ getState(input: ChangelogStateInput<Locale, SubjectId>): Promise<ChangelogSnapshot<Locale>>
121
+ acknowledge(input: ChangelogAcknowledgementInput<SubjectId>): Promise<ChangelogAcknowledgementResult>
122
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Caller-facing alias of withBypass with a name that makes review intent clear.
3
+ *
4
+ * await bypassTenant(() =>
5
+ * db.selectFrom('users').where('email', '=', email).execute()
6
+ * )
7
+ *
8
+ * Use only for legitimate cross-tenant queries:
9
+ * - OAuth callback before tenant is resolved
10
+ * - Hub admin tenant CRUD on the `tenants` table itself
11
+ * - System jobs that need to span tenants
12
+ *
13
+ * Code review should flag any other usage.
14
+ */
15
+ export { withBypass as bypassTenant } from './tenant-context.ts'