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 +105 -27
- package/dist/adapters/drizzle.d.mts +8 -2
- package/dist/adapters/drizzle.d.ts +8 -2
- package/dist/adapters/drizzle.js +11 -3
- package/dist/adapters/drizzle.mjs +11 -3
- package/dist/adapters/knex.d.mts +1 -0
- package/dist/adapters/knex.d.ts +1 -0
- package/dist/adapters/knex.js +8 -2
- package/dist/adapters/knex.mjs +8 -2
- package/dist/adapters/kysely.d.mts +1 -0
- package/dist/adapters/kysely.d.ts +1 -0
- package/dist/adapters/kysely.js +8 -2
- package/dist/adapters/kysely.mjs +8 -2
- package/dist/adapters/prisma.js +3 -2
- package/dist/adapters/prisma.mjs +3 -2
- package/dist/adapters/raw.js +3 -2
- package/dist/adapters/raw.mjs +3 -2
- package/dist/cli.js +26 -4
- package/dist/cli.mjs +26 -4
- package/dist/index.d.mts +14 -2
- package/dist/index.d.ts +14 -2
- package/dist/index.js +23 -2
- package/dist/index.mjs +23 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -430,52 +430,129 @@ normalize(" @Sarah "); // "sarah"
|
|
|
430
430
|
normalize("ACME-Corp"); // "acme-corp"
|
|
431
431
|
```
|
|
432
432
|
|
|
433
|
-
##
|
|
433
|
+
## Case-Insensitive Matching
|
|
434
434
|
|
|
435
|
-
|
|
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
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
438
|
+
const guard = createNamespaceGuard({
|
|
439
|
+
sources: [/* ... */],
|
|
440
|
+
caseInsensitive: true,
|
|
441
|
+
}, adapter);
|
|
442
|
+
```
|
|
442
443
|
|
|
443
|
-
|
|
444
|
-
|
|
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
|
-
|
|
447
|
-
|
|
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:
|
|
500
|
+
data: { handle: guard.normalize(handle), email },
|
|
453
501
|
});
|
|
454
|
-
|
|
455
502
|
return { user };
|
|
456
503
|
}
|
|
457
504
|
```
|
|
458
505
|
|
|
459
|
-
|
|
506
|
+
### Express Middleware
|
|
460
507
|
|
|
461
508
|
```typescript
|
|
462
|
-
|
|
463
|
-
|
|
509
|
+
import express from "express";
|
|
510
|
+
import { guard } from "./lib/guard";
|
|
464
511
|
|
|
465
|
-
|
|
466
|
-
const result = await guard.check(normalized, { id: userId });
|
|
512
|
+
const app = express();
|
|
467
513
|
|
|
468
|
-
|
|
469
|
-
|
|
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
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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
|
|
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>,
|
|
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
|
|
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>,
|
|
46
|
+
declare function createDrizzleAdapter(db: DrizzleDb, tables: Record<string, DrizzleTable>, eqOrOptions: ComparisonFn | DrizzleAdapterOptions): NamespaceAdapter;
|
|
41
47
|
|
|
42
48
|
export { createDrizzleAdapter };
|
package/dist/adapters/drizzle.js
CHANGED
|
@@ -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,
|
|
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:
|
|
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,
|
|
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:
|
|
27
|
+
where: compareFn(column, value),
|
|
20
28
|
columns: {
|
|
21
29
|
[idColumn]: true,
|
|
22
30
|
...source.scopeKey && source.scopeKey !== idColumn ? { [source.scopeKey]: true } : {}
|
package/dist/adapters/knex.d.mts
CHANGED
|
@@ -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 = {
|
package/dist/adapters/knex.d.ts
CHANGED
|
@@ -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 = {
|
package/dist/adapters/knex.js
CHANGED
|
@@ -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
|
-
|
|
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
|
};
|
package/dist/adapters/knex.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
};
|
package/dist/adapters/kysely.js
CHANGED
|
@@ -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
|
-
|
|
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
|
};
|
package/dist/adapters/kysely.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
};
|
package/dist/adapters/prisma.js
CHANGED
|
@@ -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]:
|
|
36
|
+
where: { [source.column]: whereValue },
|
|
36
37
|
select: {
|
|
37
38
|
[idColumn]: true,
|
|
38
39
|
...source.scopeKey ? { [source.scopeKey]: true } : {}
|
package/dist/adapters/prisma.mjs
CHANGED
|
@@ -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]:
|
|
12
|
+
where: { [source.column]: whereValue },
|
|
12
13
|
select: {
|
|
13
14
|
[idColumn]: true,
|
|
14
15
|
...source.scopeKey ? { [source.scopeKey]: true } : {}
|
package/dist/adapters/raw.js
CHANGED
|
@@ -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
|
|
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
|
}
|
package/dist/adapters/raw.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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