namespace-guard 0.4.0 → 0.6.1
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 +44 -12
- package/dist/cli.js +263 -14
- package/dist/cli.mjs +263 -14
- package/dist/index.d.mts +7 -3
- package/dist/index.d.ts +7 -3
- package/dist/index.js +268 -20
- package/dist/index.mjs +268 -20
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -298,21 +298,15 @@ No words are bundled — use any word list you like (e.g., the `bad-words` npm p
|
|
|
298
298
|
|
|
299
299
|
## Conflict Suggestions
|
|
300
300
|
|
|
301
|
-
When a slug is taken, automatically suggest available alternatives:
|
|
301
|
+
When a slug is taken, automatically suggest available alternatives using pluggable strategies:
|
|
302
302
|
|
|
303
303
|
```typescript
|
|
304
304
|
const guard = createNamespaceGuard({
|
|
305
305
|
sources: [/* ... */],
|
|
306
306
|
suggest: {
|
|
307
|
-
//
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
`${identifier}-2`,
|
|
311
|
-
`${identifier}-io`,
|
|
312
|
-
`${identifier}-app`,
|
|
313
|
-
`${identifier}-hq`,
|
|
314
|
-
],
|
|
315
|
-
// Optional: max suggestions to return (default: 3)
|
|
307
|
+
// Named strategy (default: ["sequential", "random-digits"])
|
|
308
|
+
strategy: "suffix-words",
|
|
309
|
+
// Max suggestions to return (default: 3)
|
|
316
310
|
max: 3,
|
|
317
311
|
},
|
|
318
312
|
}, adapter);
|
|
@@ -323,11 +317,48 @@ const result = await guard.check("acme-corp");
|
|
|
323
317
|
// reason: "taken",
|
|
324
318
|
// message: "That name is already in use.",
|
|
325
319
|
// source: "organization",
|
|
326
|
-
// suggestions: ["acme-corp-
|
|
320
|
+
// suggestions: ["acme-corp-dev", "acme-corp-io", "acme-corp-app"]
|
|
327
321
|
// }
|
|
328
322
|
```
|
|
329
323
|
|
|
330
|
-
|
|
324
|
+
### Built-in Strategies
|
|
325
|
+
|
|
326
|
+
| Strategy | Example Output | Description |
|
|
327
|
+
|----------|---------------|-------------|
|
|
328
|
+
| `"sequential"` | `sarah-1`, `sarah1`, `sarah-2` | Hyphenated and compact numeric suffixes |
|
|
329
|
+
| `"random-digits"` | `sarah-4821`, `sarah-1037` | Random 3-4 digit suffixes |
|
|
330
|
+
| `"suffix-words"` | `sarah-dev`, `sarah-hq`, `sarah-app` | Common word suffixes |
|
|
331
|
+
| `"short-random"` | `sarah-x7k`, `sarah-m2p` | Short 3-char alphanumeric suffixes |
|
|
332
|
+
| `"scramble"` | `asrah`, `sarha` | Adjacent character transpositions |
|
|
333
|
+
| `"similar"` | `sara`, `darah`, `thesarah` | Edit-distance-1 mutations (deletions, keyboard-adjacent substitutions, prefix/suffix) |
|
|
334
|
+
|
|
335
|
+
### Composing Strategies
|
|
336
|
+
|
|
337
|
+
Combine multiple strategies — candidates are interleaved round-robin:
|
|
338
|
+
|
|
339
|
+
```typescript
|
|
340
|
+
suggest: {
|
|
341
|
+
strategy: ["random-digits", "suffix-words"],
|
|
342
|
+
max: 4,
|
|
343
|
+
}
|
|
344
|
+
// → ["sarah-4821", "sarah-dev", "sarah-1037", "sarah-io"]
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
### Custom Strategy Function
|
|
348
|
+
|
|
349
|
+
Pass a function that returns candidate slugs:
|
|
350
|
+
|
|
351
|
+
```typescript
|
|
352
|
+
suggest: {
|
|
353
|
+
strategy: (identifier) => [
|
|
354
|
+
`${identifier}-io`,
|
|
355
|
+
`${identifier}-app`,
|
|
356
|
+
`the-real-${identifier}`,
|
|
357
|
+
],
|
|
358
|
+
}
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
Suggestions are verified against format, reserved names, validators, and database collisions using a progressive batched pipeline. Only available suggestions are returned.
|
|
331
362
|
|
|
332
363
|
## Batch Checking
|
|
333
364
|
|
|
@@ -641,6 +672,7 @@ import {
|
|
|
641
672
|
type CheckResult,
|
|
642
673
|
type FindOneOptions,
|
|
643
674
|
type OwnershipScope,
|
|
675
|
+
type SuggestStrategyName,
|
|
644
676
|
} from "namespace-guard";
|
|
645
677
|
```
|
|
646
678
|
|
package/dist/cli.js
CHANGED
|
@@ -36,29 +36,51 @@ var DEFAULT_MESSAGES = {
|
|
|
36
36
|
};
|
|
37
37
|
function extractMaxLength(pattern) {
|
|
38
38
|
const testStrings = ["a", "1", "a1", "a-1"];
|
|
39
|
-
|
|
39
|
+
let lo = 1;
|
|
40
|
+
let hi = 100;
|
|
41
|
+
let best = 30;
|
|
42
|
+
while (lo <= hi) {
|
|
43
|
+
const mid = Math.floor((lo + hi) / 2);
|
|
44
|
+
let anyMatch = false;
|
|
40
45
|
for (const chars of testStrings) {
|
|
41
|
-
const s = chars.repeat(Math.ceil(
|
|
42
|
-
if (pattern.test(s))
|
|
46
|
+
const s = chars.repeat(Math.ceil(mid / chars.length)).slice(0, mid);
|
|
47
|
+
if (pattern.test(s)) {
|
|
48
|
+
anyMatch = true;
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (anyMatch) {
|
|
53
|
+
best = mid;
|
|
54
|
+
lo = mid + 1;
|
|
55
|
+
} else {
|
|
56
|
+
hi = mid - 1;
|
|
43
57
|
}
|
|
44
58
|
}
|
|
45
|
-
return
|
|
59
|
+
return best;
|
|
46
60
|
}
|
|
47
61
|
function createDefaultSuggest(pattern) {
|
|
48
62
|
const maxLen = extractMaxLength(pattern);
|
|
49
63
|
return (identifier) => {
|
|
64
|
+
const seen = /* @__PURE__ */ new Set();
|
|
50
65
|
const candidates = [];
|
|
51
66
|
for (let i = 1; i <= 9; i++) {
|
|
52
67
|
const hyphenated = `${identifier}-${i}`;
|
|
53
|
-
if (hyphenated.length <= maxLen)
|
|
68
|
+
if (hyphenated.length <= maxLen) {
|
|
69
|
+
seen.add(hyphenated);
|
|
70
|
+
candidates.push(hyphenated);
|
|
71
|
+
}
|
|
54
72
|
const compact = `${identifier}${i}`;
|
|
55
|
-
if (compact.length <= maxLen)
|
|
73
|
+
if (compact.length <= maxLen) {
|
|
74
|
+
seen.add(compact);
|
|
75
|
+
candidates.push(compact);
|
|
76
|
+
}
|
|
56
77
|
}
|
|
57
78
|
if (identifier.length >= maxLen - 1) {
|
|
58
79
|
for (let i = 1; i <= 9; i++) {
|
|
59
80
|
const suffix = String(i);
|
|
60
81
|
const truncated = identifier.slice(0, maxLen - suffix.length) + suffix;
|
|
61
|
-
if (truncated !== identifier && !
|
|
82
|
+
if (truncated !== identifier && !seen.has(truncated)) {
|
|
83
|
+
seen.add(truncated);
|
|
62
84
|
candidates.push(truncated);
|
|
63
85
|
}
|
|
64
86
|
}
|
|
@@ -66,6 +88,184 @@ function createDefaultSuggest(pattern) {
|
|
|
66
88
|
return candidates;
|
|
67
89
|
};
|
|
68
90
|
}
|
|
91
|
+
var SUFFIX_WORDS = ["dev", "io", "app", "hq", "pro", "team", "labs", "hub", "go", "one"];
|
|
92
|
+
function createRandomDigitsStrategy(pattern) {
|
|
93
|
+
const maxLen = extractMaxLength(pattern);
|
|
94
|
+
return (identifier) => {
|
|
95
|
+
const seen = /* @__PURE__ */ new Set();
|
|
96
|
+
const candidates = [];
|
|
97
|
+
for (let i = 0; i < 15; i++) {
|
|
98
|
+
const digits = String(Math.floor(100 + Math.random() * 9900));
|
|
99
|
+
const candidate = `${identifier}-${digits}`;
|
|
100
|
+
if (candidate.length <= maxLen && !seen.has(candidate)) {
|
|
101
|
+
seen.add(candidate);
|
|
102
|
+
candidates.push(candidate);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return candidates;
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
function createSuffixWordsStrategy(pattern) {
|
|
109
|
+
const maxLen = extractMaxLength(pattern);
|
|
110
|
+
return (identifier) => {
|
|
111
|
+
const candidates = [];
|
|
112
|
+
for (const word of SUFFIX_WORDS) {
|
|
113
|
+
const candidate = `${identifier}-${word}`;
|
|
114
|
+
if (candidate.length <= maxLen) {
|
|
115
|
+
candidates.push(candidate);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return candidates;
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
function createShortRandomStrategy(pattern) {
|
|
122
|
+
const maxLen = extractMaxLength(pattern);
|
|
123
|
+
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
124
|
+
return (identifier) => {
|
|
125
|
+
const seen = /* @__PURE__ */ new Set();
|
|
126
|
+
const candidates = [];
|
|
127
|
+
for (let i = 0; i < 10; i++) {
|
|
128
|
+
let suffix = "";
|
|
129
|
+
for (let j = 0; j < 3; j++) {
|
|
130
|
+
suffix += chars[Math.floor(Math.random() * chars.length)];
|
|
131
|
+
}
|
|
132
|
+
const candidate = `${identifier}-${suffix}`;
|
|
133
|
+
if (candidate.length <= maxLen && !seen.has(candidate)) {
|
|
134
|
+
seen.add(candidate);
|
|
135
|
+
candidates.push(candidate);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return candidates;
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
function createScrambleStrategy(_pattern) {
|
|
142
|
+
return (identifier) => {
|
|
143
|
+
const seen = /* @__PURE__ */ new Set();
|
|
144
|
+
const candidates = [];
|
|
145
|
+
const chars = identifier.split("");
|
|
146
|
+
for (let i = 0; i < chars.length - 1; i++) {
|
|
147
|
+
if (chars[i] !== chars[i + 1]) {
|
|
148
|
+
const swapped = [...chars];
|
|
149
|
+
[swapped[i], swapped[i + 1]] = [swapped[i + 1], swapped[i]];
|
|
150
|
+
const candidate = swapped.join("");
|
|
151
|
+
if (candidate !== identifier && !seen.has(candidate)) {
|
|
152
|
+
seen.add(candidate);
|
|
153
|
+
candidates.push(candidate);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return candidates;
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
function createSimilarStrategy(pattern) {
|
|
161
|
+
const maxLen = extractMaxLength(pattern);
|
|
162
|
+
const nearby = {
|
|
163
|
+
a: "sqwz",
|
|
164
|
+
b: "vngh",
|
|
165
|
+
c: "xdfv",
|
|
166
|
+
d: "sfce",
|
|
167
|
+
e: "wrd",
|
|
168
|
+
f: "dgcv",
|
|
169
|
+
g: "fhtb",
|
|
170
|
+
h: "gjyn",
|
|
171
|
+
i: "uko",
|
|
172
|
+
j: "hknm",
|
|
173
|
+
k: "jli",
|
|
174
|
+
l: "kop",
|
|
175
|
+
m: "njk",
|
|
176
|
+
n: "bmhj",
|
|
177
|
+
o: "ipl",
|
|
178
|
+
p: "ol",
|
|
179
|
+
q: "wa",
|
|
180
|
+
r: "eft",
|
|
181
|
+
s: "adwz",
|
|
182
|
+
t: "rgy",
|
|
183
|
+
u: "yij",
|
|
184
|
+
v: "cfgb",
|
|
185
|
+
w: "qase",
|
|
186
|
+
x: "zsdc",
|
|
187
|
+
y: "tuh",
|
|
188
|
+
z: "xas",
|
|
189
|
+
"0": "19",
|
|
190
|
+
"1": "02",
|
|
191
|
+
"2": "13",
|
|
192
|
+
"3": "24",
|
|
193
|
+
"4": "35",
|
|
194
|
+
"5": "46",
|
|
195
|
+
"6": "57",
|
|
196
|
+
"7": "68",
|
|
197
|
+
"8": "79",
|
|
198
|
+
"9": "80"
|
|
199
|
+
};
|
|
200
|
+
const prefixes = ["the", "my", "x", "i"];
|
|
201
|
+
const suffixes = ["x", "o", "i", "z"];
|
|
202
|
+
return (identifier) => {
|
|
203
|
+
const seen = /* @__PURE__ */ new Set();
|
|
204
|
+
const candidates = [];
|
|
205
|
+
function add(c) {
|
|
206
|
+
if (c.length >= 2 && c.length <= maxLen && c !== identifier && pattern.test(c) && !seen.has(c)) {
|
|
207
|
+
seen.add(c);
|
|
208
|
+
candidates.push(c);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
for (let i = 0; i < identifier.length; i++) {
|
|
212
|
+
add(identifier.slice(0, i) + identifier.slice(i + 1));
|
|
213
|
+
}
|
|
214
|
+
for (let i = 0; i < identifier.length; i++) {
|
|
215
|
+
const ch = identifier[i];
|
|
216
|
+
const neighbours = nearby[ch] ?? "";
|
|
217
|
+
for (const n of neighbours) {
|
|
218
|
+
add(identifier.slice(0, i) + n + identifier.slice(i + 1));
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
for (const p of prefixes) {
|
|
222
|
+
add(p + identifier);
|
|
223
|
+
}
|
|
224
|
+
for (const s of suffixes) {
|
|
225
|
+
add(identifier + s);
|
|
226
|
+
}
|
|
227
|
+
return candidates;
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
function createStrategy(name, pattern) {
|
|
231
|
+
switch (name) {
|
|
232
|
+
case "sequential":
|
|
233
|
+
return createDefaultSuggest(pattern);
|
|
234
|
+
case "random-digits":
|
|
235
|
+
return createRandomDigitsStrategy(pattern);
|
|
236
|
+
case "suffix-words":
|
|
237
|
+
return createSuffixWordsStrategy(pattern);
|
|
238
|
+
case "short-random":
|
|
239
|
+
return createShortRandomStrategy(pattern);
|
|
240
|
+
case "scramble":
|
|
241
|
+
return createScrambleStrategy(pattern);
|
|
242
|
+
case "similar":
|
|
243
|
+
return createSimilarStrategy(pattern);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
function resolveGenerator(suggest, pattern) {
|
|
247
|
+
if (suggest?.generate) return suggest.generate;
|
|
248
|
+
const strategyInput = suggest?.strategy ?? ["sequential", "random-digits"];
|
|
249
|
+
if (typeof strategyInput === "function") return strategyInput;
|
|
250
|
+
const names = Array.isArray(strategyInput) ? strategyInput : [strategyInput];
|
|
251
|
+
const generators = names.map((name) => createStrategy(name, pattern));
|
|
252
|
+
if (generators.length === 1) return generators[0];
|
|
253
|
+
return (identifier) => {
|
|
254
|
+
const lists = generators.map((g) => g(identifier));
|
|
255
|
+
const seen = /* @__PURE__ */ new Set();
|
|
256
|
+
const result = [];
|
|
257
|
+
const maxListLen = Math.max(...lists.map((l) => l.length));
|
|
258
|
+
for (let i = 0; i < maxListLen; i++) {
|
|
259
|
+
for (const list of lists) {
|
|
260
|
+
if (i < list.length && !seen.has(list[i])) {
|
|
261
|
+
seen.add(list[i]);
|
|
262
|
+
result.push(list[i]);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return result;
|
|
267
|
+
};
|
|
268
|
+
}
|
|
69
269
|
function normalize(raw) {
|
|
70
270
|
return raw.trim().toLowerCase().replace(/^@+/, "");
|
|
71
271
|
}
|
|
@@ -104,6 +304,8 @@ function createNamespaceGuard(config, adapter) {
|
|
|
104
304
|
const cached = cacheMap.get(key);
|
|
105
305
|
if (cached && cached.expires > now) {
|
|
106
306
|
cacheHits++;
|
|
307
|
+
cacheMap.delete(key);
|
|
308
|
+
cacheMap.set(key, cached);
|
|
107
309
|
return cached.promise;
|
|
108
310
|
}
|
|
109
311
|
cacheMisses++;
|
|
@@ -131,6 +333,24 @@ function createNamespaceGuard(config, adapter) {
|
|
|
131
333
|
}
|
|
132
334
|
return null;
|
|
133
335
|
}
|
|
336
|
+
async function checkDbOnly(value, scope) {
|
|
337
|
+
const findOptions = config.caseInsensitive ? { caseInsensitive: true } : void 0;
|
|
338
|
+
const checks = config.sources.map(async (source) => {
|
|
339
|
+
const existing = await cachedFindOne(source, value, findOptions);
|
|
340
|
+
if (!existing) return null;
|
|
341
|
+
if (source.scopeKey) {
|
|
342
|
+
const scopeValue = scope[source.scopeKey];
|
|
343
|
+
const idColumn = source.idColumn ?? "id";
|
|
344
|
+
const existingId = existing[idColumn];
|
|
345
|
+
if (scopeValue && existingId && scopeValue === String(existingId)) {
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return source.name;
|
|
350
|
+
});
|
|
351
|
+
const results = await Promise.all(checks);
|
|
352
|
+
return !results.some((r) => r !== null);
|
|
353
|
+
}
|
|
134
354
|
async function check(identifier, scope = {}, options) {
|
|
135
355
|
const normalized = normalize(identifier);
|
|
136
356
|
if (!pattern.test(normalized)) {
|
|
@@ -180,16 +400,45 @@ function createNamespaceGuard(config, adapter) {
|
|
|
180
400
|
source: collision
|
|
181
401
|
};
|
|
182
402
|
if (config.suggest && !options?.skipSuggestions) {
|
|
183
|
-
const generate = config.suggest
|
|
403
|
+
const generate = resolveGenerator(config.suggest, pattern);
|
|
184
404
|
const max = config.suggest.max ?? 3;
|
|
185
405
|
const candidates = generate(normalized);
|
|
186
406
|
const suggestions = [];
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
407
|
+
const passedSync = candidates.filter(
|
|
408
|
+
(c) => pattern.test(c) && !reservedMap.has(c)
|
|
409
|
+
);
|
|
410
|
+
for (let i = 0; i < passedSync.length && suggestions.length < max; i += max) {
|
|
411
|
+
const batch = passedSync.slice(i, i + max);
|
|
412
|
+
let validated = batch;
|
|
413
|
+
if (validators.length > 0) {
|
|
414
|
+
const validationResults = await Promise.all(
|
|
415
|
+
batch.map(async (c) => {
|
|
416
|
+
for (const validator of validators) {
|
|
417
|
+
try {
|
|
418
|
+
const rejection = await validator(c);
|
|
419
|
+
if (rejection) return null;
|
|
420
|
+
} catch {
|
|
421
|
+
return null;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
return c;
|
|
425
|
+
})
|
|
426
|
+
);
|
|
427
|
+
validated = validationResults.filter(
|
|
428
|
+
(c) => c !== null
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
if (validated.length > 0) {
|
|
432
|
+
const dbResults = await Promise.all(
|
|
433
|
+
validated.map(async (c) => ({
|
|
434
|
+
candidate: c,
|
|
435
|
+
available: await checkDbOnly(c, scope)
|
|
436
|
+
}))
|
|
437
|
+
);
|
|
438
|
+
for (const { candidate, available } of dbResults) {
|
|
439
|
+
if (suggestions.length >= max) break;
|
|
440
|
+
if (available) suggestions.push(candidate);
|
|
441
|
+
}
|
|
193
442
|
}
|
|
194
443
|
}
|
|
195
444
|
if (suggestions.length > 0) {
|