nucleus-core-ts 0.10.113 → 0.10.115

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.
@@ -35,6 +35,15 @@ export declare function serverRequestId(response: {
35
35
  * `requiresCaptcha` one the control-flag list above exists for.
36
36
  */
37
37
  export declare function normalizeResponseShape(response: unknown): unknown;
38
+ /**
39
+ * Rewrite a `cookie` request header so it carries the values the backend just
40
+ * issued.
41
+ *
42
+ * The retry has to send the NEW access token. Headers here are built from the
43
+ * INCOMING request, so without this the second attempt re-sends the same expired
44
+ * token and earns the same 401 — a refresh that changes nothing.
45
+ */
46
+ export declare function mergeCookieHeader(existing: string | undefined, setCookies: string[]): string;
38
47
  export declare function createServerFactory<TEndpoints extends Record<string, EndpointDefinition>>(endpoints: TEndpoints, config: ServerFactoryConfig, getCookies: () => Promise<CookieStore>, getHeaders: () => Promise<HeadersStore>): <K extends keyof TEndpoints & string>(endpointKey: K, payload: TEndpoints[K]["_payload"]) => Promise<ApiResponse<TEndpoints[K]["_success"], TEndpoints[K]["_error"]>>;
39
48
  export type ServerFactory<TEndpoints extends Record<string, EndpointDefinition>> = ReturnType<typeof createServerFactory<TEndpoints>>;
40
49
  /**
@@ -2472,6 +2472,24 @@ export interface ServerFactoryConfig {
2472
2472
  sessionToken?: string;
2473
2473
  };
2474
2474
  debug?: boolean;
2475
+ /**
2476
+ * What to do when the backend answers 401 because the access token has aged
2477
+ * out. Without this the caller simply sees the 401 and the person is bounced
2478
+ * to the login screen mid-task — on a thirty-minute token, every thirty
2479
+ * minutes, no matter how recently they typed their password.
2480
+ */
2481
+ refresh?: {
2482
+ /** Default: true. */
2483
+ enabled?: boolean;
2484
+ /** Where to ask. Default: `/auth/refresh`. */
2485
+ endpoint?: string;
2486
+ /**
2487
+ * Paths that must never trigger a refresh, because a 401 from them IS the
2488
+ * answer. Matched as substrings of the endpoint path. Defaults to the
2489
+ * login, logout, register and refresh routes themselves.
2490
+ */
2491
+ excludePaths?: string[];
2492
+ };
2475
2493
  }
2476
2494
  export interface ClientHookConfig<TEndpoints extends Record<string, EndpointDefinition>> {
2477
2495
  endpoints: TEndpoints;
@@ -0,0 +1,16 @@
1
+ import { type SQL } from 'drizzle-orm';
2
+ export declare const COLLATABLE_COLUMN_TYPES: Set<string>;
3
+ /**
4
+ * A collation name is an IDENTIFIER, so it is interpolated into the SQL text
5
+ * and cannot be sent as a bound parameter. It comes from config rather than
6
+ * from a request, but "not attacker-controlled today" is not the same as safe:
7
+ * accept a bare identifier and nothing else.
8
+ */
9
+ export declare function normalizeSortCollation(raw: unknown): string;
10
+ /** True when `raw` was given but is not usable — the caller should log it. */
11
+ export declare function isRejectedSortCollation(raw: unknown): boolean;
12
+ /**
13
+ * Wraps a sort column in the configured collation when — and only when — the
14
+ * column is a character type.
15
+ */
16
+ export declare function collateColumn(col: unknown, collation: string): SQL;
@@ -30,6 +30,8 @@ export interface EntityRouteConfig {
30
30
  entities: NucleusTable[];
31
31
  logger: Logger;
32
32
  databaseUrl?: string;
33
+ /** ICU collation for text-column sorts (see `database.sortCollation`). */
34
+ sortCollation?: string;
33
35
  dbPool?: import('pg').Pool | null;
34
36
  storage?: {
35
37
  enabled?: boolean;
@@ -26,6 +26,15 @@ export interface NucleusColumnValidation {
26
26
  customMessage?: string;
27
27
  }
28
28
  export type NucleusSanitizeOption = 'trim' | 'lowercase' | 'uppercase' | 'escapeHtml' | 'stripTags' | 'normalizeEmail' | 'toNumber' | 'toBoolean' | 'slugify';
29
+ export interface NucleusColumnIdentity {
30
+ /** First value the sequence hands out. Set it above the largest number already stored. */
31
+ startWith?: number;
32
+ increment?: number;
33
+ minValue?: number;
34
+ maxValue?: number;
35
+ cache?: number;
36
+ cycle?: boolean;
37
+ }
29
38
  export interface NucleusColumn {
30
39
  name: string;
31
40
  type: NucleusColumnType;
@@ -49,6 +58,12 @@ export interface NucleusColumn {
49
58
  generatedAlwaysAs?: string;
50
59
  generatedAlwaysAsIdentity?: boolean;
51
60
  generatedByDefaultAsIdentity?: boolean;
61
+ /**
62
+ * Where an identity column's numbering starts and how it steps. Needed when a
63
+ * column adopts identity over a table that already has rows: the sequence
64
+ * would otherwise restart at 1 and hand out numbers those rows already carry.
65
+ */
66
+ identity?: NucleusColumnIdentity;
52
67
  check?: string;
53
68
  comment?: string;
54
69
  validation?: NucleusColumnValidation;
@@ -210,6 +225,24 @@ export interface NucleusConfigOptions {
210
225
  maxUses?: number;
211
226
  };
212
227
  isMultiTenant?: boolean;
228
+ /**
229
+ * Collation applied to text-column sorts, e.g. `tr-TR-x-icu`.
230
+ *
231
+ * Postgres' default collation on a `C`/`POSIX` database orders by BYTE, so
232
+ * an "A-Z" list is not alphabetical in any language with letters outside
233
+ * ASCII. Measured on a Turkish catalogue: `ALBITROL` sorted before
234
+ * `Airtide` (uppercase letters sort before lowercase), and every name
235
+ * starting with ç/ğ/ı/ö/ş/ü landed after `z`. The list looked sorted and
236
+ * was not — the worst kind of wrong, because nothing about it invites a
237
+ * second look.
238
+ *
239
+ * Set the ICU collation for the install's language (`tr-TR-x-icu`,
240
+ * `de-DE-x-icu`, ...) and every entity sort on a text column uses it.
241
+ * Unset (default) keeps the database's own collation, so existing installs
242
+ * do not change order under them. An unknown collation is refused at boot
243
+ * rather than applied — see the entity route.
244
+ */
245
+ sortCollation?: string;
213
246
  schemas?: string[];
214
247
  /**
215
248
  * Deadline for the startup schema push (default 120000 ms).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nucleus-core-ts",
3
- "version": "0.10.113",
3
+ "version": "0.10.115",
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",
@@ -273,3 +273,79 @@ describe('generateRelationsFile', () => {
273
273
  expect(code).toContain('surveys: one(surveys')
274
274
  })
275
275
  })
276
+
277
+ /**
278
+ * Postgres starts a new identity sequence at 1.
279
+ *
280
+ * On an empty table that is correct. On a table that already holds rows it
281
+ * hands the next insert a number those rows already carry — and identity adds
282
+ * no unique index, so nothing objects. Biltim's `five_s_findings` is the case:
283
+ * `finding_no` is NOT NULL with no default, nothing in the app has ever
284
+ * assigned it, and every attempt to record a finding was refused with
285
+ * "finding_no is required". Giving the column identity fixes the refusal, and
286
+ * would then have produced a second finding numbered 1 sitting beside the
287
+ * first, in a register whose Excel export orders by that number.
288
+ */
289
+ describe('an identity column can say where its numbering resumes', () => {
290
+ const emit = (col: Record<string, unknown>) => generateColumnCode(col as never, [], 'findings')
291
+
292
+ it('still emits a bare identity when nothing is asked for', () => {
293
+ // The existing behaviour, unchanged: adding the option must not alter what
294
+ // every column that does not use it generates.
295
+ expect(emit({ name: 'finding_no', type: 'integer', generatedByDefaultAsIdentity: true })).toBe(
296
+ "integer('finding_no').generatedByDefaultAsIdentity()"
297
+ )
298
+ })
299
+
300
+ it('carries the starting value through to Drizzle', () => {
301
+ expect(
302
+ emit({
303
+ name: 'finding_no',
304
+ type: 'integer',
305
+ generatedByDefaultAsIdentity: true,
306
+ identity: { startWith: 3 },
307
+ })
308
+ ).toContain('.generatedByDefaultAsIdentity({ startWith: 3 })')
309
+ })
310
+
311
+ it('carries the rest of the sequence options', () => {
312
+ const code = emit({
313
+ name: 'ticket_no',
314
+ type: 'integer',
315
+ generatedAlwaysAsIdentity: true,
316
+ identity: { startWith: 1000, increment: 10, minValue: 1, maxValue: 999999, cache: 5 },
317
+ })
318
+
319
+ expect(code).toContain('startWith: 1000')
320
+ expect(code).toContain('increment: 10')
321
+ expect(code).toContain('minValue: 1')
322
+ expect(code).toContain('maxValue: 999999')
323
+ expect(code).toContain('cache: 5')
324
+ })
325
+
326
+ it('an identity column still does not repeat notNull', () => {
327
+ // Identity implies NOT NULL; emitting both produced a schema Drizzle
328
+ // rejected. The option must not reopen that.
329
+ const code = emit({
330
+ name: 'finding_no',
331
+ type: 'integer',
332
+ notNull: true,
333
+ generatedByDefaultAsIdentity: true,
334
+ identity: { startWith: 3 },
335
+ })
336
+
337
+ expect(code).not.toContain('.notNull()')
338
+ })
339
+
340
+ it('still applies a unique index when one is declared', () => {
341
+ const code = emit({
342
+ name: 'finding_no',
343
+ type: 'integer',
344
+ generatedByDefaultAsIdentity: true,
345
+ unique: true,
346
+ identity: { startWith: 3 },
347
+ })
348
+
349
+ expect(code).toContain('.unique()')
350
+ })
351
+ })
@@ -33,6 +33,14 @@ interface Column {
33
33
  generatedAlwaysAs?: string
34
34
  generatedByDefaultAsIdentity?: boolean
35
35
  generatedAlwaysAsIdentity?: boolean
36
+ identity?: {
37
+ startWith?: number
38
+ increment?: number
39
+ minValue?: number
40
+ maxValue?: number
41
+ cache?: number
42
+ cycle?: boolean
43
+ }
36
44
  comment?: string
37
45
  check?: string
38
46
  sensitive?: boolean
@@ -228,6 +236,30 @@ export function selectEmittedTables<T extends { table_name: string }>(
228
236
  )
229
237
  }
230
238
 
239
+ /**
240
+ * Identity sequence options, rendered for Drizzle.
241
+ *
242
+ * Postgres starts a new identity sequence at 1. On an empty table that is
243
+ * right; on a table that already holds rows it is not, and nothing says so —
244
+ * the next insert is handed 1 again and sits alongside the row that already
245
+ * carries it. Without a unique index (identity does not add one) there is no
246
+ * error, just two records wearing the same number and a report that orders by
247
+ * it. So a column adopting identity over existing data has to be able to say
248
+ * where the numbering resumes.
249
+ */
250
+ function identityOptions(col: Column): string {
251
+ const id = col.identity
252
+ if (!id) return ''
253
+ const parts: string[] = []
254
+ if (id.startWith !== undefined) parts.push(`startWith: ${id.startWith}`)
255
+ if (id.increment !== undefined) parts.push(`increment: ${id.increment}`)
256
+ if (id.minValue !== undefined) parts.push(`minValue: ${id.minValue}`)
257
+ if (id.maxValue !== undefined) parts.push(`maxValue: ${id.maxValue}`)
258
+ if (id.cache !== undefined) parts.push(`cache: ${id.cache}`)
259
+ if (id.cycle !== undefined) parts.push(`cycle: ${id.cycle}`)
260
+ return parts.length ? `{ ${parts.join(', ')} }` : ''
261
+ }
262
+
231
263
  export function generateColumnCode(
232
264
  col: Column,
233
265
  allTables: string[],
@@ -260,12 +292,12 @@ export function generateColumnCode(
260
292
  if (col.primaryKey) code += '.primaryKey()'
261
293
 
262
294
  if (col.generatedByDefaultAsIdentity) {
263
- code += '.generatedByDefaultAsIdentity()'
295
+ code += `.generatedByDefaultAsIdentity(${identityOptions(col)})`
264
296
  if (col.unique) code += '.unique()'
265
297
  return code
266
298
  }
267
299
  if (col.generatedAlwaysAsIdentity) {
268
- code += '.generatedAlwaysAsIdentity()'
300
+ code += `.generatedAlwaysAsIdentity(${identityOptions(col)})`
269
301
  if (col.unique) code += '.unique()'
270
302
  return code
271
303
  }