namespace-guard 0.1.2 → 0.2.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/README.md CHANGED
@@ -430,52 +430,129 @@ normalize(" @Sarah "); // "sarah"
430
430
  normalize("ACME-Corp"); // "acme-corp"
431
431
  ```
432
432
 
433
- ## Real-World Example
433
+ ## Case-Insensitive Matching
434
434
 
435
- Here's how you might use this in a signup flow:
435
+ By default, slug lookups are case-sensitive. Enable case-insensitive matching to catch collisions regardless of stored casing:
436
436
 
437
437
  ```typescript
438
- // In your API route / server action
439
- async function createUser(handle: string, email: string) {
440
- // Normalize first
441
- const normalizedHandle = guard.normalize(handle);
438
+ const guard = createNamespaceGuard({
439
+ sources: [/* ... */],
440
+ caseInsensitive: true,
441
+ }, adapter);
442
+ ```
442
443
 
443
- // Check availability (will also validate format)
444
- const result = await guard.check(normalizedHandle);
444
+ Each adapter handles this differently:
445
+ - **Prisma**: Uses `mode: "insensitive"` on the where clause
446
+ - **Drizzle**: Uses `ilike` instead of `eq` (pass `ilike` to the adapter: `createDrizzleAdapter(db, tables, { eq, ilike })`)
447
+ - **Kysely**: Uses `ilike` operator
448
+ - **Knex**: Uses `LOWER()` in a raw where clause
449
+ - **Raw SQL**: Wraps both sides in `LOWER()`
445
450
 
446
- if (!result.available) {
447
- return { error: result.message };
448
- }
451
+ ## Caching
452
+
453
+ Enable in-memory caching to reduce database calls during rapid checks (e.g., live form validation, suggestion generation):
454
+
455
+ ```typescript
456
+ const guard = createNamespaceGuard({
457
+ sources: [/* ... */],
458
+ cache: {
459
+ ttl: 5000, // milliseconds (default: 5000)
460
+ },
461
+ }, adapter);
462
+
463
+ // Manually clear the cache after writes
464
+ guard.clearCache();
465
+ ```
466
+
467
+ ## Framework Integration
468
+
469
+ ### Next.js (Server Actions)
470
+
471
+ ```typescript
472
+ // lib/guard.ts
473
+ import { createNamespaceGuard } from "namespace-guard";
474
+ import { createPrismaAdapter } from "namespace-guard/adapters/prisma";
475
+ import { prisma } from "./db";
476
+
477
+ export const guard = createNamespaceGuard({
478
+ reserved: ["admin", "api", "settings"],
479
+ sources: [
480
+ { name: "user", column: "handle", scopeKey: "id" },
481
+ { name: "organization", column: "slug", scopeKey: "id" },
482
+ ],
483
+ suggest: {},
484
+ }, createPrismaAdapter(prisma));
485
+
486
+ // app/signup/actions.ts
487
+ "use server";
488
+
489
+ import { guard } from "@/lib/guard";
490
+
491
+ export async function checkHandle(handle: string) {
492
+ return guard.check(handle);
493
+ }
494
+
495
+ export async function createUser(handle: string, email: string) {
496
+ const result = await guard.check(handle);
497
+ if (!result.available) return { error: result.message };
449
498
 
450
- // Safe to create
451
499
  const user = await prisma.user.create({
452
- data: { handle: normalizedHandle, email },
500
+ data: { handle: guard.normalize(handle), email },
453
501
  });
454
-
455
502
  return { user };
456
503
  }
457
504
  ```
458
505
 
459
- Or in an update flow with ownership scoping:
506
+ ### Express Middleware
460
507
 
461
508
  ```typescript
462
- async function updateUserHandle(userId: string, newHandle: string) {
463
- const normalized = guard.normalize(newHandle);
509
+ import express from "express";
510
+ import { guard } from "./lib/guard";
464
511
 
465
- // Pass userId to avoid collision with own current handle
466
- const result = await guard.check(normalized, { id: userId });
512
+ const app = express();
467
513
 
468
- if (!result.available) {
469
- return { error: result.message };
470
- }
514
+ // Reusable middleware
515
+ function validateSlug(req, res, next) {
516
+ const slug = req.body.handle || req.body.slug;
517
+ if (!slug) return res.status(400).json({ error: "Slug is required" });
471
518
 
472
- await prisma.user.update({
473
- where: { id: userId },
474
- data: { handle: normalized },
519
+ guard.check(slug, { id: req.user?.id }).then((result) => {
520
+ if (!result.available) return res.status(409).json(result);
521
+ req.normalizedSlug = guard.normalize(slug);
522
+ next();
475
523
  });
476
-
477
- return { success: true };
478
524
  }
525
+
526
+ app.post("/api/users", validateSlug, async (req, res) => {
527
+ const user = await db.user.create({ handle: req.normalizedSlug, ... });
528
+ res.json({ user });
529
+ });
530
+ ```
531
+
532
+ ### tRPC
533
+
534
+ ```typescript
535
+ import { z } from "zod";
536
+ import { router, protectedProcedure } from "./trpc";
537
+ import { guard } from "./lib/guard";
538
+
539
+ export const namespaceRouter = router({
540
+ check: protectedProcedure
541
+ .input(z.object({ slug: z.string() }))
542
+ .query(async ({ input, ctx }) => {
543
+ return guard.check(input.slug, { id: ctx.user.id });
544
+ }),
545
+
546
+ claim: protectedProcedure
547
+ .input(z.object({ slug: z.string() }))
548
+ .mutation(async ({ input, ctx }) => {
549
+ await guard.assertAvailable(input.slug, { id: ctx.user.id });
550
+ return ctx.db.user.update({
551
+ where: { id: ctx.user.id },
552
+ data: { handle: guard.normalize(input.slug) },
553
+ });
554
+ }),
555
+ });
479
556
  ```
480
557
 
481
558
  ## TypeScript
@@ -489,6 +566,7 @@ import type {
489
566
  NamespaceAdapter,
490
567
  NamespaceGuard,
491
568
  CheckResult,
569
+ FindOneOptions,
492
570
  OwnershipScope,
493
571
  } from "namespace-guard";
494
572
  ```
@@ -13,7 +13,13 @@ type DrizzleDb = {
13
13
  };
14
14
  };
15
15
  };
16
- type EqFn = (column: unknown, value: unknown) => unknown;
16
+ type ComparisonFn = (column: unknown, value: unknown) => unknown;
17
+ type DrizzleAdapterOptions = {
18
+ /** The `eq` function from drizzle-orm */
19
+ eq: ComparisonFn;
20
+ /** The `ilike` function from drizzle-orm (required when using caseInsensitive) */
21
+ ilike?: ComparisonFn;
22
+ };
17
23
  /**
18
24
  * Create a namespace adapter for Drizzle ORM
19
25
  *
@@ -37,6 +43,6 @@ type EqFn = (column: unknown, value: unknown) => unknown;
37
43
  * );
38
44
  * ```
39
45
  */
40
- declare function createDrizzleAdapter(db: DrizzleDb, tables: Record<string, DrizzleTable>, eq: EqFn): NamespaceAdapter;
46
+ declare function createDrizzleAdapter(db: DrizzleDb, tables: Record<string, DrizzleTable>, eqOrOptions: ComparisonFn | DrizzleAdapterOptions): NamespaceAdapter;
41
47
 
42
48
  export { createDrizzleAdapter };
@@ -13,7 +13,13 @@ type DrizzleDb = {
13
13
  };
14
14
  };
15
15
  };
16
- type EqFn = (column: unknown, value: unknown) => unknown;
16
+ type ComparisonFn = (column: unknown, value: unknown) => unknown;
17
+ type DrizzleAdapterOptions = {
18
+ /** The `eq` function from drizzle-orm */
19
+ eq: ComparisonFn;
20
+ /** The `ilike` function from drizzle-orm (required when using caseInsensitive) */
21
+ ilike?: ComparisonFn;
22
+ };
17
23
  /**
18
24
  * Create a namespace adapter for Drizzle ORM
19
25
  *
@@ -37,6 +43,6 @@ type EqFn = (column: unknown, value: unknown) => unknown;
37
43
  * );
38
44
  * ```
39
45
  */
40
- declare function createDrizzleAdapter(db: DrizzleDb, tables: Record<string, DrizzleTable>, eq: EqFn): NamespaceAdapter;
46
+ declare function createDrizzleAdapter(db: DrizzleDb, tables: Record<string, DrizzleTable>, eqOrOptions: ComparisonFn | DrizzleAdapterOptions): NamespaceAdapter;
41
47
 
42
48
  export { createDrizzleAdapter };
@@ -23,9 +23,10 @@ __export(drizzle_exports, {
23
23
  createDrizzleAdapter: () => createDrizzleAdapter
24
24
  });
25
25
  module.exports = __toCommonJS(drizzle_exports);
26
- function createDrizzleAdapter(db, tables, eq) {
26
+ function createDrizzleAdapter(db, tables, eqOrOptions) {
27
+ const ops = typeof eqOrOptions === "function" ? { eq: eqOrOptions } : eqOrOptions;
27
28
  return {
28
- async findOne(source, value) {
29
+ async findOne(source, value, findOptions) {
29
30
  const queryHandler = db.query[source.name];
30
31
  if (!queryHandler) {
31
32
  throw new Error(`Drizzle query handler for "${source.name}" not found. Make sure relational queries are set up.`);
@@ -39,8 +40,15 @@ function createDrizzleAdapter(db, tables, eq) {
39
40
  throw new Error(`Column "${source.column}" not found in table "${source.name}"`);
40
41
  }
41
42
  const idColumn = source.idColumn ?? "id";
43
+ let compareFn = ops.eq;
44
+ if (findOptions?.caseInsensitive) {
45
+ if (!ops.ilike) {
46
+ throw new Error("caseInsensitive requires passing ilike to createDrizzleAdapter");
47
+ }
48
+ compareFn = ops.ilike;
49
+ }
42
50
  return queryHandler.findFirst({
43
- where: eq(column, value),
51
+ where: compareFn(column, value),
44
52
  columns: {
45
53
  [idColumn]: true,
46
54
  ...source.scopeKey && source.scopeKey !== idColumn ? { [source.scopeKey]: true } : {}
@@ -1,7 +1,8 @@
1
1
  // src/adapters/drizzle.ts
2
- function createDrizzleAdapter(db, tables, eq) {
2
+ function createDrizzleAdapter(db, tables, eqOrOptions) {
3
+ const ops = typeof eqOrOptions === "function" ? { eq: eqOrOptions } : eqOrOptions;
3
4
  return {
4
- async findOne(source, value) {
5
+ async findOne(source, value, findOptions) {
5
6
  const queryHandler = db.query[source.name];
6
7
  if (!queryHandler) {
7
8
  throw new Error(`Drizzle query handler for "${source.name}" not found. Make sure relational queries are set up.`);
@@ -15,8 +16,15 @@ function createDrizzleAdapter(db, tables, eq) {
15
16
  throw new Error(`Column "${source.column}" not found in table "${source.name}"`);
16
17
  }
17
18
  const idColumn = source.idColumn ?? "id";
19
+ let compareFn = ops.eq;
20
+ if (findOptions?.caseInsensitive) {
21
+ if (!ops.ilike) {
22
+ throw new Error("caseInsensitive requires passing ilike to createDrizzleAdapter");
23
+ }
24
+ compareFn = ops.ilike;
25
+ }
18
26
  return queryHandler.findFirst({
19
- where: eq(column, value),
27
+ where: compareFn(column, value),
20
28
  columns: {
21
29
  [idColumn]: true,
22
30
  ...source.scopeKey && source.scopeKey !== idColumn ? { [source.scopeKey]: true } : {}
@@ -3,6 +3,7 @@ import { NamespaceAdapter } from '../index.mjs';
3
3
  type KnexQueryBuilder = {
4
4
  select: (columns: string[]) => KnexQueryBuilder;
5
5
  where: (column: string, value: unknown) => KnexQueryBuilder;
6
+ whereRaw: (raw: string, bindings: unknown[]) => KnexQueryBuilder;
6
7
  first: () => Promise<Record<string, unknown> | undefined>;
7
8
  };
8
9
  type KnexInstance = {
@@ -3,6 +3,7 @@ import { NamespaceAdapter } from '../index.js';
3
3
  type KnexQueryBuilder = {
4
4
  select: (columns: string[]) => KnexQueryBuilder;
5
5
  where: (column: string, value: unknown) => KnexQueryBuilder;
6
+ whereRaw: (raw: string, bindings: unknown[]) => KnexQueryBuilder;
6
7
  first: () => Promise<Record<string, unknown> | undefined>;
7
8
  };
8
9
  type KnexInstance = {
@@ -25,10 +25,16 @@ __export(knex_exports, {
25
25
  module.exports = __toCommonJS(knex_exports);
26
26
  function createKnexAdapter(knex) {
27
27
  return {
28
- async findOne(source, value) {
28
+ async findOne(source, value, options) {
29
29
  const idColumn = source.idColumn ?? "id";
30
30
  const columns = source.scopeKey && source.scopeKey !== idColumn ? [idColumn, source.scopeKey] : [idColumn];
31
- const row = await knex(source.name).select(columns).where(source.column, value).first();
31
+ let query = knex(source.name).select(columns);
32
+ if (options?.caseInsensitive) {
33
+ query = query.whereRaw(`LOWER("${source.column}") = LOWER(?)`, [value]);
34
+ } else {
35
+ query = query.where(source.column, value);
36
+ }
37
+ const row = await query.first();
32
38
  return row ?? null;
33
39
  }
34
40
  };
@@ -1,10 +1,16 @@
1
1
  // src/adapters/knex.ts
2
2
  function createKnexAdapter(knex) {
3
3
  return {
4
- async findOne(source, value) {
4
+ async findOne(source, value, options) {
5
5
  const idColumn = source.idColumn ?? "id";
6
6
  const columns = source.scopeKey && source.scopeKey !== idColumn ? [idColumn, source.scopeKey] : [idColumn];
7
- const row = await knex(source.name).select(columns).where(source.column, value).first();
7
+ let query = knex(source.name).select(columns);
8
+ if (options?.caseInsensitive) {
9
+ query = query.whereRaw(`LOWER("${source.column}") = LOWER(?)`, [value]);
10
+ } else {
11
+ query = query.where(source.column, value);
12
+ }
13
+ const row = await query.first();
8
14
  return row ?? null;
9
15
  }
10
16
  };
@@ -3,6 +3,7 @@ import { NamespaceAdapter } from '../index.mjs';
3
3
  type KyselyQueryBuilder = {
4
4
  select: (columns: string[]) => KyselyQueryBuilder;
5
5
  where: (column: string, operator: string, value: unknown) => KyselyQueryBuilder;
6
+ whereRaw: (raw: unknown) => KyselyQueryBuilder;
6
7
  limit: (limit: number) => KyselyQueryBuilder;
7
8
  executeTakeFirst: () => Promise<Record<string, unknown> | undefined>;
8
9
  };
@@ -3,6 +3,7 @@ import { NamespaceAdapter } from '../index.js';
3
3
  type KyselyQueryBuilder = {
4
4
  select: (columns: string[]) => KyselyQueryBuilder;
5
5
  where: (column: string, operator: string, value: unknown) => KyselyQueryBuilder;
6
+ whereRaw: (raw: unknown) => KyselyQueryBuilder;
6
7
  limit: (limit: number) => KyselyQueryBuilder;
7
8
  executeTakeFirst: () => Promise<Record<string, unknown> | undefined>;
8
9
  };
@@ -25,10 +25,16 @@ __export(kysely_exports, {
25
25
  module.exports = __toCommonJS(kysely_exports);
26
26
  function createKyselyAdapter(db) {
27
27
  return {
28
- async findOne(source, value) {
28
+ async findOne(source, value, options) {
29
29
  const idColumn = source.idColumn ?? "id";
30
30
  const columns = source.scopeKey && source.scopeKey !== idColumn ? [idColumn, source.scopeKey] : [idColumn];
31
- const row = await db.selectFrom(source.name).select(columns).where(source.column, "=", value).limit(1).executeTakeFirst();
31
+ let query = db.selectFrom(source.name).select(columns);
32
+ if (options?.caseInsensitive) {
33
+ query = query.where(source.column, "ilike", value);
34
+ } else {
35
+ query = query.where(source.column, "=", value);
36
+ }
37
+ const row = await query.limit(1).executeTakeFirst();
32
38
  return row ?? null;
33
39
  }
34
40
  };
@@ -1,10 +1,16 @@
1
1
  // src/adapters/kysely.ts
2
2
  function createKyselyAdapter(db) {
3
3
  return {
4
- async findOne(source, value) {
4
+ async findOne(source, value, options) {
5
5
  const idColumn = source.idColumn ?? "id";
6
6
  const columns = source.scopeKey && source.scopeKey !== idColumn ? [idColumn, source.scopeKey] : [idColumn];
7
- const row = await db.selectFrom(source.name).select(columns).where(source.column, "=", value).limit(1).executeTakeFirst();
7
+ let query = db.selectFrom(source.name).select(columns);
8
+ if (options?.caseInsensitive) {
9
+ query = query.where(source.column, "ilike", value);
10
+ } else {
11
+ query = query.where(source.column, "=", value);
12
+ }
13
+ const row = await query.limit(1).executeTakeFirst();
8
14
  return row ?? null;
9
15
  }
10
16
  };
@@ -25,14 +25,15 @@ __export(prisma_exports, {
25
25
  module.exports = __toCommonJS(prisma_exports);
26
26
  function createPrismaAdapter(prisma) {
27
27
  return {
28
- async findOne(source, value) {
28
+ async findOne(source, value, options) {
29
29
  const model = prisma[source.name];
30
30
  if (!model) {
31
31
  throw new Error(`Prisma model "${source.name}" not found`);
32
32
  }
33
33
  const idColumn = source.idColumn ?? "id";
34
+ const whereValue = options?.caseInsensitive ? { equals: value, mode: "insensitive" } : value;
34
35
  return model.findFirst({
35
- where: { [source.column]: value },
36
+ where: { [source.column]: whereValue },
36
37
  select: {
37
38
  [idColumn]: true,
38
39
  ...source.scopeKey ? { [source.scopeKey]: true } : {}
@@ -1,14 +1,15 @@
1
1
  // src/adapters/prisma.ts
2
2
  function createPrismaAdapter(prisma) {
3
3
  return {
4
- async findOne(source, value) {
4
+ async findOne(source, value, options) {
5
5
  const model = prisma[source.name];
6
6
  if (!model) {
7
7
  throw new Error(`Prisma model "${source.name}" not found`);
8
8
  }
9
9
  const idColumn = source.idColumn ?? "id";
10
+ const whereValue = options?.caseInsensitive ? { equals: value, mode: "insensitive" } : value;
10
11
  return model.findFirst({
11
- where: { [source.column]: value },
12
+ where: { [source.column]: whereValue },
12
13
  select: {
13
14
  [idColumn]: true,
14
15
  ...source.scopeKey ? { [source.scopeKey]: true } : {}
@@ -25,10 +25,11 @@ __export(raw_exports, {
25
25
  module.exports = __toCommonJS(raw_exports);
26
26
  function createRawAdapter(execute) {
27
27
  return {
28
- async findOne(source, value) {
28
+ async findOne(source, value, options) {
29
29
  const idColumn = source.idColumn ?? "id";
30
30
  const columns = source.scopeKey && source.scopeKey !== idColumn ? `"${idColumn}", "${source.scopeKey}"` : `"${idColumn}"`;
31
- const sql = `SELECT ${columns} FROM "${source.name}" WHERE "${source.column}" = $1 LIMIT 1`;
31
+ const whereClause = options?.caseInsensitive ? `LOWER("${source.column}") = LOWER($1)` : `"${source.column}" = $1`;
32
+ const sql = `SELECT ${columns} FROM "${source.name}" WHERE ${whereClause} LIMIT 1`;
32
33
  const result = await execute(sql, [value]);
33
34
  return result.rows[0] ?? null;
34
35
  }
@@ -1,10 +1,11 @@
1
1
  // src/adapters/raw.ts
2
2
  function createRawAdapter(execute) {
3
3
  return {
4
- async findOne(source, value) {
4
+ async findOne(source, value, options) {
5
5
  const idColumn = source.idColumn ?? "id";
6
6
  const columns = source.scopeKey && source.scopeKey !== idColumn ? `"${idColumn}", "${source.scopeKey}"` : `"${idColumn}"`;
7
- const sql = `SELECT ${columns} FROM "${source.name}" WHERE "${source.column}" = $1 LIMIT 1`;
7
+ const whereClause = options?.caseInsensitive ? `LOWER("${source.column}") = LOWER($1)` : `"${source.column}" = $1`;
8
+ const sql = `SELECT ${columns} FROM "${source.name}" WHERE ${whereClause} LIMIT 1`;
8
9
  const result = await execute(sql, [value]);
9
10
  return result.rows[0] ?? null;
10
11
  }
package/dist/cli.js CHANGED
@@ -62,6 +62,22 @@ function createNamespaceGuard(config, adapter) {
62
62
  const invalidMsg = configMessages.invalid ?? DEFAULT_MESSAGES.invalid;
63
63
  const takenMsg = configMessages.taken ?? DEFAULT_MESSAGES.taken;
64
64
  const validators = config.validators ?? [];
65
+ const cacheEnabled = !!config.cache;
66
+ const cacheTtl = config.cache?.ttl ?? 5e3;
67
+ const cacheMap = /* @__PURE__ */ new Map();
68
+ function cachedFindOne(source, value, options) {
69
+ if (!cacheEnabled) return adapter.findOne(source, value, options);
70
+ const key = `${source.name}:${value}:${options?.caseInsensitive ? "i" : "s"}`;
71
+ const now = Date.now();
72
+ const cached = cacheMap.get(key);
73
+ if (cached && cached.expires > now) {
74
+ return Promise.resolve(cached.value);
75
+ }
76
+ return adapter.findOne(source, value, options).then((result) => {
77
+ cacheMap.set(key, { value: result, expires: now + cacheTtl });
78
+ return result;
79
+ });
80
+ }
65
81
  function getReservedMessage(category) {
66
82
  const rm = configMessages.reserved;
67
83
  if (typeof rm === "string") return rm;
@@ -98,8 +114,9 @@ function createNamespaceGuard(config, adapter) {
98
114
  return { available: false, reason: "invalid", message: rejection.message };
99
115
  }
100
116
  }
117
+ const findOptions = config.caseInsensitive ? { caseInsensitive: true } : void 0;
101
118
  const checks = config.sources.map(async (source) => {
102
- const existing = await adapter.findOne(source, normalized);
119
+ const existing = await cachedFindOne(source, normalized, findOptions);
103
120
  if (!existing) return null;
104
121
  if (source.scopeKey) {
105
122
  const scopeValue = scope[source.scopeKey];
@@ -155,22 +172,27 @@ function createNamespaceGuard(config, adapter) {
155
172
  );
156
173
  return Object.fromEntries(entries);
157
174
  }
175
+ function clearCache() {
176
+ cacheMap.clear();
177
+ }
158
178
  return {
159
179
  normalize,
160
180
  validateFormat,
161
181
  check,
162
182
  assertAvailable,
163
- checkMany
183
+ checkMany,
184
+ clearCache
164
185
  };
165
186
  }
166
187
 
167
188
  // src/adapters/raw.ts
168
189
  function createRawAdapter(execute) {
169
190
  return {
170
- async findOne(source, value) {
191
+ async findOne(source, value, options) {
171
192
  const idColumn = source.idColumn ?? "id";
172
193
  const columns = source.scopeKey && source.scopeKey !== idColumn ? `"${idColumn}", "${source.scopeKey}"` : `"${idColumn}"`;
173
- const sql = `SELECT ${columns} FROM "${source.name}" WHERE "${source.column}" = $1 LIMIT 1`;
194
+ const whereClause = options?.caseInsensitive ? `LOWER("${source.column}") = LOWER($1)` : `"${source.column}" = $1`;
195
+ const sql = `SELECT ${columns} FROM "${source.name}" WHERE ${whereClause} LIMIT 1`;
174
196
  const result = await execute(sql, [value]);
175
197
  return result.rows[0] ?? null;
176
198
  }
package/dist/cli.mjs CHANGED
@@ -39,6 +39,22 @@ function createNamespaceGuard(config, adapter) {
39
39
  const invalidMsg = configMessages.invalid ?? DEFAULT_MESSAGES.invalid;
40
40
  const takenMsg = configMessages.taken ?? DEFAULT_MESSAGES.taken;
41
41
  const validators = config.validators ?? [];
42
+ const cacheEnabled = !!config.cache;
43
+ const cacheTtl = config.cache?.ttl ?? 5e3;
44
+ const cacheMap = /* @__PURE__ */ new Map();
45
+ function cachedFindOne(source, value, options) {
46
+ if (!cacheEnabled) return adapter.findOne(source, value, options);
47
+ const key = `${source.name}:${value}:${options?.caseInsensitive ? "i" : "s"}`;
48
+ const now = Date.now();
49
+ const cached = cacheMap.get(key);
50
+ if (cached && cached.expires > now) {
51
+ return Promise.resolve(cached.value);
52
+ }
53
+ return adapter.findOne(source, value, options).then((result) => {
54
+ cacheMap.set(key, { value: result, expires: now + cacheTtl });
55
+ return result;
56
+ });
57
+ }
42
58
  function getReservedMessage(category) {
43
59
  const rm = configMessages.reserved;
44
60
  if (typeof rm === "string") return rm;
@@ -75,8 +91,9 @@ function createNamespaceGuard(config, adapter) {
75
91
  return { available: false, reason: "invalid", message: rejection.message };
76
92
  }
77
93
  }
94
+ const findOptions = config.caseInsensitive ? { caseInsensitive: true } : void 0;
78
95
  const checks = config.sources.map(async (source) => {
79
- const existing = await adapter.findOne(source, normalized);
96
+ const existing = await cachedFindOne(source, normalized, findOptions);
80
97
  if (!existing) return null;
81
98
  if (source.scopeKey) {
82
99
  const scopeValue = scope[source.scopeKey];
@@ -132,22 +149,27 @@ function createNamespaceGuard(config, adapter) {
132
149
  );
133
150
  return Object.fromEntries(entries);
134
151
  }
152
+ function clearCache() {
153
+ cacheMap.clear();
154
+ }
135
155
  return {
136
156
  normalize,
137
157
  validateFormat,
138
158
  check,
139
159
  assertAvailable,
140
- checkMany
160
+ checkMany,
161
+ clearCache
141
162
  };
142
163
  }
143
164
 
144
165
  // src/adapters/raw.ts
145
166
  function createRawAdapter(execute) {
146
167
  return {
147
- async findOne(source, value) {
168
+ async findOne(source, value, options) {
148
169
  const idColumn = source.idColumn ?? "id";
149
170
  const columns = source.scopeKey && source.scopeKey !== idColumn ? `"${idColumn}", "${source.scopeKey}"` : `"${idColumn}"`;
150
- const sql = `SELECT ${columns} FROM "${source.name}" WHERE "${source.column}" = $1 LIMIT 1`;
171
+ const whereClause = options?.caseInsensitive ? `LOWER("${source.column}") = LOWER($1)` : `"${source.column}" = $1`;
172
+ const sql = `SELECT ${columns} FROM "${source.name}" WHERE ${whereClause} LIMIT 1`;
151
173
  const result = await execute(sql, [value]);
152
174
  return result.rows[0] ?? null;
153
175
  }
package/dist/index.d.mts CHANGED
@@ -15,6 +15,8 @@ type NamespaceConfig = {
15
15
  sources: NamespaceSource[];
16
16
  /** Regex pattern for valid identifiers (default: lowercase alphanumeric + hyphens, 2-30 chars) */
17
17
  pattern?: RegExp;
18
+ /** Use case-insensitive matching in database queries (default: false) */
19
+ caseInsensitive?: boolean;
18
20
  /** Custom error messages */
19
21
  messages?: {
20
22
  invalid?: string;
@@ -33,9 +35,18 @@ type NamespaceConfig = {
33
35
  /** Max suggestions to return (default: 3) */
34
36
  max?: number;
35
37
  };
38
+ /** Enable in-memory caching of adapter lookups */
39
+ cache?: {
40
+ /** Time-to-live in milliseconds (default: 5000) */
41
+ ttl?: number;
42
+ };
43
+ };
44
+ type FindOneOptions = {
45
+ /** Use case-insensitive matching */
46
+ caseInsensitive?: boolean;
36
47
  };
37
48
  type NamespaceAdapter = {
38
- findOne: (source: NamespaceSource, value: string) => Promise<Record<string, unknown> | null>;
49
+ findOne: (source: NamespaceSource, value: string, options?: FindOneOptions) => Promise<Record<string, unknown> | null>;
39
50
  };
40
51
  type OwnershipScope = Record<string, string | null | undefined>;
41
52
  type CheckResult = {
@@ -63,7 +74,8 @@ declare function createNamespaceGuard(config: NamespaceConfig, adapter: Namespac
63
74
  }) => Promise<CheckResult>;
64
75
  assertAvailable: (identifier: string, scope?: OwnershipScope) => Promise<void>;
65
76
  checkMany: (identifiers: string[], scope?: OwnershipScope) => Promise<Record<string, CheckResult>>;
77
+ clearCache: () => void;
66
78
  };
67
79
  type NamespaceGuard = ReturnType<typeof createNamespaceGuard>;
68
80
 
69
- export { type CheckResult, type NamespaceAdapter, type NamespaceConfig, type NamespaceGuard, type NamespaceSource, type OwnershipScope, createNamespaceGuard, normalize };
81
+ export { type CheckResult, type FindOneOptions, type NamespaceAdapter, type NamespaceConfig, type NamespaceGuard, type NamespaceSource, type OwnershipScope, createNamespaceGuard, normalize };
package/dist/index.d.ts CHANGED
@@ -15,6 +15,8 @@ type NamespaceConfig = {
15
15
  sources: NamespaceSource[];
16
16
  /** Regex pattern for valid identifiers (default: lowercase alphanumeric + hyphens, 2-30 chars) */
17
17
  pattern?: RegExp;
18
+ /** Use case-insensitive matching in database queries (default: false) */
19
+ caseInsensitive?: boolean;
18
20
  /** Custom error messages */
19
21
  messages?: {
20
22
  invalid?: string;
@@ -33,9 +35,18 @@ type NamespaceConfig = {
33
35
  /** Max suggestions to return (default: 3) */
34
36
  max?: number;
35
37
  };
38
+ /** Enable in-memory caching of adapter lookups */
39
+ cache?: {
40
+ /** Time-to-live in milliseconds (default: 5000) */
41
+ ttl?: number;
42
+ };
43
+ };
44
+ type FindOneOptions = {
45
+ /** Use case-insensitive matching */
46
+ caseInsensitive?: boolean;
36
47
  };
37
48
  type NamespaceAdapter = {
38
- findOne: (source: NamespaceSource, value: string) => Promise<Record<string, unknown> | null>;
49
+ findOne: (source: NamespaceSource, value: string, options?: FindOneOptions) => Promise<Record<string, unknown> | null>;
39
50
  };
40
51
  type OwnershipScope = Record<string, string | null | undefined>;
41
52
  type CheckResult = {
@@ -63,7 +74,8 @@ declare function createNamespaceGuard(config: NamespaceConfig, adapter: Namespac
63
74
  }) => Promise<CheckResult>;
64
75
  assertAvailable: (identifier: string, scope?: OwnershipScope) => Promise<void>;
65
76
  checkMany: (identifiers: string[], scope?: OwnershipScope) => Promise<Record<string, CheckResult>>;
77
+ clearCache: () => void;
66
78
  };
67
79
  type NamespaceGuard = ReturnType<typeof createNamespaceGuard>;
68
80
 
69
- export { type CheckResult, type NamespaceAdapter, type NamespaceConfig, type NamespaceGuard, type NamespaceSource, type OwnershipScope, createNamespaceGuard, normalize };
81
+ export { type CheckResult, type FindOneOptions, type NamespaceAdapter, type NamespaceConfig, type NamespaceGuard, type NamespaceSource, type OwnershipScope, createNamespaceGuard, normalize };
package/dist/index.js CHANGED
@@ -58,6 +58,22 @@ function createNamespaceGuard(config, adapter) {
58
58
  const invalidMsg = configMessages.invalid ?? DEFAULT_MESSAGES.invalid;
59
59
  const takenMsg = configMessages.taken ?? DEFAULT_MESSAGES.taken;
60
60
  const validators = config.validators ?? [];
61
+ const cacheEnabled = !!config.cache;
62
+ const cacheTtl = config.cache?.ttl ?? 5e3;
63
+ const cacheMap = /* @__PURE__ */ new Map();
64
+ function cachedFindOne(source, value, options) {
65
+ if (!cacheEnabled) return adapter.findOne(source, value, options);
66
+ const key = `${source.name}:${value}:${options?.caseInsensitive ? "i" : "s"}`;
67
+ const now = Date.now();
68
+ const cached = cacheMap.get(key);
69
+ if (cached && cached.expires > now) {
70
+ return Promise.resolve(cached.value);
71
+ }
72
+ return adapter.findOne(source, value, options).then((result) => {
73
+ cacheMap.set(key, { value: result, expires: now + cacheTtl });
74
+ return result;
75
+ });
76
+ }
61
77
  function getReservedMessage(category) {
62
78
  const rm = configMessages.reserved;
63
79
  if (typeof rm === "string") return rm;
@@ -94,8 +110,9 @@ function createNamespaceGuard(config, adapter) {
94
110
  return { available: false, reason: "invalid", message: rejection.message };
95
111
  }
96
112
  }
113
+ const findOptions = config.caseInsensitive ? { caseInsensitive: true } : void 0;
97
114
  const checks = config.sources.map(async (source) => {
98
- const existing = await adapter.findOne(source, normalized);
115
+ const existing = await cachedFindOne(source, normalized, findOptions);
99
116
  if (!existing) return null;
100
117
  if (source.scopeKey) {
101
118
  const scopeValue = scope[source.scopeKey];
@@ -151,12 +168,16 @@ function createNamespaceGuard(config, adapter) {
151
168
  );
152
169
  return Object.fromEntries(entries);
153
170
  }
171
+ function clearCache() {
172
+ cacheMap.clear();
173
+ }
154
174
  return {
155
175
  normalize,
156
176
  validateFormat,
157
177
  check,
158
178
  assertAvailable,
159
- checkMany
179
+ checkMany,
180
+ clearCache
160
181
  };
161
182
  }
162
183
  // Annotate the CommonJS export names for ESM import in node:
package/dist/index.mjs CHANGED
@@ -33,6 +33,22 @@ function createNamespaceGuard(config, adapter) {
33
33
  const invalidMsg = configMessages.invalid ?? DEFAULT_MESSAGES.invalid;
34
34
  const takenMsg = configMessages.taken ?? DEFAULT_MESSAGES.taken;
35
35
  const validators = config.validators ?? [];
36
+ const cacheEnabled = !!config.cache;
37
+ const cacheTtl = config.cache?.ttl ?? 5e3;
38
+ const cacheMap = /* @__PURE__ */ new Map();
39
+ function cachedFindOne(source, value, options) {
40
+ if (!cacheEnabled) return adapter.findOne(source, value, options);
41
+ const key = `${source.name}:${value}:${options?.caseInsensitive ? "i" : "s"}`;
42
+ const now = Date.now();
43
+ const cached = cacheMap.get(key);
44
+ if (cached && cached.expires > now) {
45
+ return Promise.resolve(cached.value);
46
+ }
47
+ return adapter.findOne(source, value, options).then((result) => {
48
+ cacheMap.set(key, { value: result, expires: now + cacheTtl });
49
+ return result;
50
+ });
51
+ }
36
52
  function getReservedMessage(category) {
37
53
  const rm = configMessages.reserved;
38
54
  if (typeof rm === "string") return rm;
@@ -69,8 +85,9 @@ function createNamespaceGuard(config, adapter) {
69
85
  return { available: false, reason: "invalid", message: rejection.message };
70
86
  }
71
87
  }
88
+ const findOptions = config.caseInsensitive ? { caseInsensitive: true } : void 0;
72
89
  const checks = config.sources.map(async (source) => {
73
- const existing = await adapter.findOne(source, normalized);
90
+ const existing = await cachedFindOne(source, normalized, findOptions);
74
91
  if (!existing) return null;
75
92
  if (source.scopeKey) {
76
93
  const scopeValue = scope[source.scopeKey];
@@ -126,12 +143,16 @@ function createNamespaceGuard(config, adapter) {
126
143
  );
127
144
  return Object.fromEntries(entries);
128
145
  }
146
+ function clearCache() {
147
+ cacheMap.clear();
148
+ }
129
149
  return {
130
150
  normalize,
131
151
  validateFormat,
132
152
  check,
133
153
  assertAvailable,
134
- checkMany
154
+ checkMany,
155
+ clearCache
135
156
  };
136
157
  }
137
158
  export {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "namespace-guard",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Check slug/handle uniqueness across multiple database tables with reserved name protection",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",