thaizip 0.7.2 → 0.7.4

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/dist/index.cjs CHANGED
@@ -4,8 +4,13 @@
4
4
  var TONE_MARKS = /[่-๋็์]/g;
5
5
  var ADDRESS_PREFIXES = /^(จังหวัด|อำเภอ|ตำบล|แขวง|เขต|จ\.|อ\.|ต\.|ข\.)/;
6
6
  function normalizeThaiAddressText(input) {
7
- if (!input) return "";
8
- let text = input.trim();
7
+ if (typeof input !== "string") {
8
+ throw new TypeError(
9
+ `[thaizip] normalizeThaiAddressText expected string, got ${input === null ? "null" : typeof input}`
10
+ );
11
+ }
12
+ if (input.length === 0) return "";
13
+ let text = input.normalize("NFC").trim();
9
14
  text = text.replace(ADDRESS_PREFIXES, "").trim();
10
15
  text = text.replace(TONE_MARKS, "");
11
16
  text = text.toLowerCase();
@@ -33,24 +38,57 @@ function extractTrigramsNormalized(text) {
33
38
  function typeErr(table, id, field, expected, value) {
34
39
  return new TypeError(`[thaizip] ${table} ${id}: expected ${expected} for ${field}, got ${typeof value}`);
35
40
  }
41
+ function typeName(value) {
42
+ return value === null ? "null" : typeof value;
43
+ }
36
44
  function validateRawData(data) {
45
+ if (data === null || typeof data !== "object") {
46
+ throw new TypeError(`[thaizip] validateRawData expected a RawData object, got ${typeName(data)}`);
47
+ }
37
48
  const { provinces, amphures, tambons } = data;
49
+ if (!Array.isArray(provinces)) {
50
+ throw new TypeError(`[thaizip] RawData.provinces must be an array, got ${typeName(provinces)}`);
51
+ }
52
+ if (!Array.isArray(amphures)) {
53
+ throw new TypeError(`[thaizip] RawData.amphures must be an array, got ${typeName(amphures)}`);
54
+ }
55
+ if (!Array.isArray(tambons)) {
56
+ throw new TypeError(`[thaizip] RawData.tambons must be an array, got ${typeName(tambons)}`);
57
+ }
58
+ const seenProvinceIds = /* @__PURE__ */ new Set();
38
59
  for (let i = 0; i < provinces.length; i++) {
39
60
  const p = provinces[i];
61
+ if (p === null || typeof p !== "object") {
62
+ throw new TypeError(`[thaizip] province[${i}]: expected object, got ${typeName(p)}`);
63
+ }
40
64
  if (typeof p.id !== "number") throw typeErr("province", p.id, "id", "number", p.id);
65
+ if (seenProvinceIds.has(p.id)) throw new TypeError(`[thaizip] duplicate province id: ${p.id}`);
66
+ seenProvinceIds.add(p.id);
41
67
  if (typeof p.name_th !== "string") throw typeErr("province", p.id, "name_th", "string", p.name_th);
42
68
  if (typeof p.name_en !== "string") throw typeErr("province", p.id, "name_en", "string", p.name_en);
43
69
  }
70
+ const seenAmphureIds = /* @__PURE__ */ new Set();
44
71
  for (let i = 0; i < amphures.length; i++) {
45
72
  const a = amphures[i];
73
+ if (a === null || typeof a !== "object") {
74
+ throw new TypeError(`[thaizip] amphure[${i}]: expected object, got ${typeName(a)}`);
75
+ }
46
76
  if (typeof a.id !== "number") throw typeErr("amphure", a.id, "id", "number", a.id);
77
+ if (seenAmphureIds.has(a.id)) throw new TypeError(`[thaizip] duplicate amphure id: ${a.id}`);
78
+ seenAmphureIds.add(a.id);
47
79
  if (typeof a.name_th !== "string") throw typeErr("amphure", a.id, "name_th", "string", a.name_th);
48
80
  if (typeof a.name_en !== "string") throw typeErr("amphure", a.id, "name_en", "string", a.name_en);
49
81
  if (typeof a.province_id !== "number") throw typeErr("amphure", a.id, "province_id", "number", a.province_id);
50
82
  }
83
+ const seenTambonIds = /* @__PURE__ */ new Set();
51
84
  for (let i = 0; i < tambons.length; i++) {
52
85
  const t = tambons[i];
86
+ if (t === null || typeof t !== "object") {
87
+ throw new TypeError(`[thaizip] tambon[${i}]: expected object, got ${typeName(t)}`);
88
+ }
53
89
  if (typeof t.id !== "number") throw typeErr("tambon", t.id, "id", "number", t.id);
90
+ if (seenTambonIds.has(t.id)) throw new TypeError(`[thaizip] duplicate tambon id: ${t.id}`);
91
+ seenTambonIds.add(t.id);
54
92
  if (typeof t.name_th !== "string") throw typeErr("tambon", t.id, "name_th", "string", t.name_th);
55
93
  if (typeof t.name_en !== "string") throw typeErr("tambon", t.id, "name_en", "string", t.name_en);
56
94
  const zipType = typeof t.zip_code;
@@ -147,7 +185,9 @@ function buildThaiAddressIndex(data, options) {
147
185
  addTrigrams(map, provTrigrams.get(province.id), idx);
148
186
  addTrigrams(map, ampTrigrams.get(amphure.id), idx);
149
187
  }
150
- return { map, records, zipIndex, normTambon, normTambonEn, byProvince, byAmphure };
188
+ const sortedZipKeys = [...zipIndex.keys()].sort();
189
+ const sortedZipPostings = sortedZipKeys.map((z) => zipIndex.get(z));
190
+ return { map, records, zipIndex, sortedZipKeys, sortedZipPostings, normTambon, normTambonEn, byProvince, byAmphure };
151
191
  }
152
192
 
153
193
  // src/core/romanize.ts
@@ -268,27 +308,44 @@ function compareZip(a, b) {
268
308
  if (a > b) return 1;
269
309
  return 0;
270
310
  }
271
- function sortZipMatches(matches, exactZip) {
272
- matches.sort((a, b) => {
273
- if (a.zipCode === exactZip && b.zipCode !== exactZip) return -1;
274
- if (b.zipCode === exactZip && a.zipCode !== exactZip) return 1;
275
- return compareZip(a.zipCode, b.zipCode);
276
- });
277
- return matches;
311
+ function normalizeLimit(value) {
312
+ if (Number.isNaN(value) || value === -Infinity) return 0;
313
+ if (value === Infinity) return value;
314
+ return Math.max(0, Math.floor(value));
278
315
  }
279
316
  function lookupByZipCode(index, zip, options) {
280
- if (!index || !zip) return [];
317
+ if (!index || !index.zipIndex || !index.records || typeof zip !== "string") return [];
318
+ if (zip.length > 1e3) return [];
319
+ const zipLimit = normalizeLimit(options?.zipLimit ?? Infinity);
320
+ if (zipLimit === 0) return [];
281
321
  const normalized = zip.trim();
282
322
  if (!ZIP_CODE_RE.test(normalized) || normalized.length < 2) return [];
283
- const zipLimit = options?.zipLimit ?? Infinity;
284
323
  const matches = [];
285
- for (const [z, indices] of index.zipIndex) {
286
- if (z.startsWith(normalized)) {
287
- for (const idx of indices) matches.push(index.records[idx]);
324
+ const keys = index.sortedZipKeys;
325
+ const postings = index.sortedZipPostings;
326
+ if (keys !== void 0 && postings !== void 0) {
327
+ let lo = 0;
328
+ let hi = keys.length;
329
+ while (lo < hi) {
330
+ const mid = lo + hi >> 1;
331
+ if (keys[mid] < normalized) lo = mid + 1;
332
+ else hi = mid;
333
+ }
334
+ for (let i = lo; i < keys.length && keys[i].startsWith(normalized) && matches.length < zipLimit; i++) {
335
+ const indices = postings[i];
336
+ for (let j = 0; j < indices.length && matches.length < zipLimit; j++) matches.push(index.records[indices[j]]);
337
+ }
338
+ } else {
339
+ const matchingKeys = [...index.zipIndex.keys()].filter((z) => z.startsWith(normalized)).sort(compareZip);
340
+ for (const z of matchingKeys) {
341
+ if (matches.length >= zipLimit) break;
342
+ for (const idx of index.zipIndex.get(z)) {
343
+ if (matches.length >= zipLimit) break;
344
+ matches.push(index.records[idx]);
345
+ }
288
346
  }
289
347
  }
290
- sortZipMatches(matches, normalized);
291
- return matches.slice(0, zipLimit);
348
+ return matches.slice(0, zipLimit).map((record) => ({ ...record }));
292
349
  }
293
350
  function rankAgainstName(name, query) {
294
351
  if (name === void 0 || name.length === 0) return 0;
@@ -303,10 +360,20 @@ function computeMatchRank(index, idx, query) {
303
360
  const en = rankAgainstName(index.normTambonEn?.[idx], query);
304
361
  return en > th ? en : th;
305
362
  }
363
+ var scratchByIndex = /* @__PURE__ */ new WeakMap();
364
+ function getScratch(index) {
365
+ const n = index.records.length;
366
+ let s = scratchByIndex.get(index);
367
+ if (s === void 0 || s.counts.length < n) {
368
+ s = { counts: new Uint32Array(n), touched: new Int32Array(n) };
369
+ scratchByIndex.set(index, s);
370
+ }
371
+ return s;
372
+ }
306
373
  function searchThaiAddress(index, query, options) {
307
- const limit = options?.limit ?? 10;
374
+ const limit = normalizeLimit(options?.limit ?? 10);
308
375
  const threshold = options?.threshold ?? 0.4;
309
- if (!index || !query) return [];
376
+ if (!index || !index.map || !index.records || typeof query !== "string") return [];
310
377
  if (query.length > 1e3) return [];
311
378
  const normalized = normalizeThaiAddressText(query);
312
379
  if (normalized.length === 0) return [];
@@ -318,19 +385,25 @@ function searchThaiAddress(index, query, options) {
318
385
  });
319
386
  }
320
387
  if (normalized.length < 3) return [];
388
+ if (limit === 0) return [];
321
389
  const searchText = options?.romanizationAliases !== false && HAS_LATIN_RE.test(normalized) ? applyRomanizationAliases(normalized) : normalized;
322
390
  const queryTrigrams = extractTrigramsNormalized(searchText);
323
- const hits = /* @__PURE__ */ new Map();
391
+ const { counts, touched } = getScratch(index);
392
+ let touchedLen = 0;
324
393
  for (const trigram of queryTrigrams) {
325
394
  const candidates = index.map.get(trigram);
326
395
  if (!candidates) continue;
327
396
  for (const idx of candidates) {
328
- hits.set(idx, (hits.get(idx) ?? 0) + 1);
397
+ if (counts[idx] === 0) touched[touchedLen++] = idx;
398
+ counts[idx]++;
329
399
  }
330
400
  }
331
401
  const scored = [];
332
- for (const [idx, count] of hits) {
333
- const score = count / queryTrigrams.size;
402
+ const totalTrigrams = queryTrigrams.size;
403
+ for (let i = 0; i < touchedLen; i++) {
404
+ const idx = touched[i];
405
+ const score = counts[idx] / totalTrigrams;
406
+ counts[idx] = 0;
334
407
  if (score >= threshold) {
335
408
  scored.push({ idx, score, matchRank: computeMatchRank(index, idx, searchText) });
336
409
  }
@@ -339,7 +412,11 @@ function searchThaiAddress(index, query, options) {
339
412
  if (b.score !== a.score) return b.score - a.score;
340
413
  return b.matchRank - a.matchRank;
341
414
  });
342
- const windowSize = Math.max(limit * 4, 50);
415
+ let windowSize = Math.min(limit, scored.length);
416
+ const boundary = scored[windowSize - 1];
417
+ while (windowSize < scored.length && scored[windowSize].score === boundary.score && scored[windowSize].matchRank === boundary.matchRank) {
418
+ windowSize++;
419
+ }
343
420
  const window = scored.slice(0, windowSize);
344
421
  window.sort((a, b) => {
345
422
  if (b.score !== a.score) return b.score - a.score;
@@ -348,7 +425,7 @@ function searchThaiAddress(index, query, options) {
348
425
  const rb = index.records[b.idx];
349
426
  return TH_COLLATOR.compare(ra.provinceNameTh, rb.provinceNameTh) || TH_COLLATOR.compare(ra.amphureNameTh, rb.amphureNameTh) || TH_COLLATOR.compare(ra.tambonNameTh, rb.tambonNameTh);
350
427
  });
351
- return window.slice(0, limit).map(({ idx }) => index.records[idx]);
428
+ return window.slice(0, limit).map(({ idx }) => ({ ...index.records[idx] }));
352
429
  }
353
430
 
354
431
  // src/core/enumerate.ts
@@ -357,6 +434,7 @@ function byNameTh(a, b) {
357
434
  return thCollator.compare(a.nameTh, b.nameTh);
358
435
  }
359
436
  function listProvinces(index) {
437
+ if (!index || !index.records) return [];
360
438
  const seen = /* @__PURE__ */ new Map();
361
439
  if (index.byProvince) {
362
440
  for (const [provinceId, recordIndices] of index.byProvince) {
@@ -374,6 +452,7 @@ function listProvinces(index) {
374
452
  return Array.from(seen.values()).sort(byNameTh);
375
453
  }
376
454
  function listAmphures(index, provinceId) {
455
+ if (!index || !index.records) return [];
377
456
  const seen = /* @__PURE__ */ new Map();
378
457
  const addFrom = (r) => {
379
458
  if (!seen.has(r.amphureId)) {
@@ -397,6 +476,7 @@ function listAmphures(index, provinceId) {
397
476
  return Array.from(seen.values()).sort(byNameTh);
398
477
  }
399
478
  function listTambons(index, amphureId) {
479
+ if (!index || !index.records) return [];
400
480
  const toSummary = (r) => ({
401
481
  id: r.tambonId,
402
482
  nameTh: r.tambonNameTh,
package/dist/index.d.cts CHANGED
@@ -14,6 +14,15 @@ type TrigramIndex = {
14
14
  map: Map<string, Set<number>>;
15
15
  records: ThaiAddressRecord[];
16
16
  zipIndex: Map<string, number[]>;
17
+ /**
18
+ * Zip codes of `zipIndex` in ascending lexical order, with postings
19
+ * parallel in `sortedZipPostings`. Used by `lookupByZipCode` for
20
+ * O(log n + matches) prefix lookup instead of scanning every zip code.
21
+ * Optional: older-shaped or hand-built indexes without these fields fall
22
+ * back to the O(n_zips) scan.
23
+ */
24
+ sortedZipKeys?: string[];
25
+ sortedZipPostings?: number[][];
17
26
  /**
18
27
  * Normalized Thai tambon names, parallel to `records` (same index).
19
28
  * Used by the search ranker for exact/prefix match detection without
@@ -156,6 +165,12 @@ type RawData = {
156
165
  * offending record's `id`, the field, and the actual `typeof` on the first bad
157
166
  * field found. Does not filter soft-deleted rows first — validates every row as
158
167
  * provided.
168
+ *
169
+ * IDs must be unique within each table (duplicates throw, they are not silently
170
+ * discarded — two suggestions sharing an id would resolve to the wrong record).
171
+ * Referential integrity (amphure→province, tambon→amphure) is intentionally NOT
172
+ * enforced: dangling references — including soft-deleted parents — are handled at
173
+ * build time, where the tambon is skipped and reported via `options.onSkip`.
159
174
  */
160
175
  declare function validateRawData(data: RawData): void;
161
176
  declare function buildThaiAddressIndex(data: RawData, options?: BuildIndexOptions): TrigramIndex;
@@ -164,9 +179,23 @@ declare function buildThaiAddressIndex(data: RawData, options?: BuildIndexOption
164
179
  * Look up records by zip code (exact or prefix match). Exact matches are
165
180
  * returned before prefix matches; ties are ordered by ascending zip code.
166
181
  *
182
+ * Uses the build-time `sortedZipKeys`/`sortedZipPostings` arrays with a
183
+ * binary search for the first key ≥ the query, then walks the contiguous
184
+ * prefix range — O(log n_zips + matches). Ascending key order places an
185
+ * exact match before any longer prefix match automatically, so no explicit
186
+ * exact-first sort is needed. Indexes without the sorted arrays (older
187
+ * shape, hand-built) scan and sort matching keys before collecting records.
188
+ * Both paths stop collecting when `zipLimit` is reached.
189
+ *
190
+ * Returned records are shallow copies: mutating them cannot corrupt the
191
+ * shared index (see `searchThaiAddress`).
192
+ *
167
193
  * Unlike `searchThaiAddress`, results are NOT capped by `options.limit` —
168
194
  * only by `options.zipLimit` (defaults to `Infinity`), since a zip code can
169
195
  * legitimately map to dozens of tambons (e.g. `45000` → 33 records).
196
+ *
197
+ * Malformed runtime input (non-string `zip`, missing index shape) and raw
198
+ * strings longer than 1000 characters return `[]`.
170
199
  */
171
200
  declare function lookupByZipCode(index: TrigramIndex, zip: string, options?: SearchOptions): ThaiAddressRecord[];
172
201
  declare function searchThaiAddress(index: TrigramIndex, query: string, options?: SearchOptions): ThaiAddressRecord[];
@@ -174,13 +203,15 @@ declare function searchThaiAddress(index: TrigramIndex, query: string, options?:
174
203
  /**
175
204
  * All provinces present in the index, deduplicated and sorted by Thai name.
176
205
  * Backed by `index.byProvince` (O(unique provinces)); falls back to scanning
177
- * `index.records` if an older-shaped index lacks that map.
206
+ * `index.records` if an older-shaped index lacks that map. A missing/junk
207
+ * index returns `[]` rather than throwing.
178
208
  */
179
209
  declare function listProvinces(index: TrigramIndex): ProvinceSummary[];
180
210
  /**
181
211
  * Amphures (districts) within a given province, deduplicated and sorted by
182
212
  * Thai name. Unknown `provinceId` returns `[]`. Backed by `index.byProvince`;
183
213
  * falls back to scanning `index.records` if an older-shaped index lacks it.
214
+ * A missing/junk index returns `[]` rather than throwing.
184
215
  */
185
216
  declare function listAmphures(index: TrigramIndex, provinceId: number): AmphureSummary[];
186
217
  /**
@@ -188,7 +219,8 @@ declare function listAmphures(index: TrigramIndex, provinceId: number): AmphureS
188
219
  * Unknown `amphureId` returns `[]`. Backed by `index.byAmphure`; falls back
189
220
  * to scanning `index.records` if an older-shaped index lacks it. Each
190
221
  * `byAmphure` entry already maps to exactly one tambon record, so no
191
- * deduplication is needed here.
222
+ * deduplication is needed here. A missing/junk index returns `[]` rather
223
+ * than throwing.
192
224
  */
193
225
  declare function listTambons(index: TrigramIndex, amphureId: number): TambonSummary[];
194
226
 
package/dist/index.d.ts CHANGED
@@ -14,6 +14,15 @@ type TrigramIndex = {
14
14
  map: Map<string, Set<number>>;
15
15
  records: ThaiAddressRecord[];
16
16
  zipIndex: Map<string, number[]>;
17
+ /**
18
+ * Zip codes of `zipIndex` in ascending lexical order, with postings
19
+ * parallel in `sortedZipPostings`. Used by `lookupByZipCode` for
20
+ * O(log n + matches) prefix lookup instead of scanning every zip code.
21
+ * Optional: older-shaped or hand-built indexes without these fields fall
22
+ * back to the O(n_zips) scan.
23
+ */
24
+ sortedZipKeys?: string[];
25
+ sortedZipPostings?: number[][];
17
26
  /**
18
27
  * Normalized Thai tambon names, parallel to `records` (same index).
19
28
  * Used by the search ranker for exact/prefix match detection without
@@ -156,6 +165,12 @@ type RawData = {
156
165
  * offending record's `id`, the field, and the actual `typeof` on the first bad
157
166
  * field found. Does not filter soft-deleted rows first — validates every row as
158
167
  * provided.
168
+ *
169
+ * IDs must be unique within each table (duplicates throw, they are not silently
170
+ * discarded — two suggestions sharing an id would resolve to the wrong record).
171
+ * Referential integrity (amphure→province, tambon→amphure) is intentionally NOT
172
+ * enforced: dangling references — including soft-deleted parents — are handled at
173
+ * build time, where the tambon is skipped and reported via `options.onSkip`.
159
174
  */
160
175
  declare function validateRawData(data: RawData): void;
161
176
  declare function buildThaiAddressIndex(data: RawData, options?: BuildIndexOptions): TrigramIndex;
@@ -164,9 +179,23 @@ declare function buildThaiAddressIndex(data: RawData, options?: BuildIndexOption
164
179
  * Look up records by zip code (exact or prefix match). Exact matches are
165
180
  * returned before prefix matches; ties are ordered by ascending zip code.
166
181
  *
182
+ * Uses the build-time `sortedZipKeys`/`sortedZipPostings` arrays with a
183
+ * binary search for the first key ≥ the query, then walks the contiguous
184
+ * prefix range — O(log n_zips + matches). Ascending key order places an
185
+ * exact match before any longer prefix match automatically, so no explicit
186
+ * exact-first sort is needed. Indexes without the sorted arrays (older
187
+ * shape, hand-built) scan and sort matching keys before collecting records.
188
+ * Both paths stop collecting when `zipLimit` is reached.
189
+ *
190
+ * Returned records are shallow copies: mutating them cannot corrupt the
191
+ * shared index (see `searchThaiAddress`).
192
+ *
167
193
  * Unlike `searchThaiAddress`, results are NOT capped by `options.limit` —
168
194
  * only by `options.zipLimit` (defaults to `Infinity`), since a zip code can
169
195
  * legitimately map to dozens of tambons (e.g. `45000` → 33 records).
196
+ *
197
+ * Malformed runtime input (non-string `zip`, missing index shape) and raw
198
+ * strings longer than 1000 characters return `[]`.
170
199
  */
171
200
  declare function lookupByZipCode(index: TrigramIndex, zip: string, options?: SearchOptions): ThaiAddressRecord[];
172
201
  declare function searchThaiAddress(index: TrigramIndex, query: string, options?: SearchOptions): ThaiAddressRecord[];
@@ -174,13 +203,15 @@ declare function searchThaiAddress(index: TrigramIndex, query: string, options?:
174
203
  /**
175
204
  * All provinces present in the index, deduplicated and sorted by Thai name.
176
205
  * Backed by `index.byProvince` (O(unique provinces)); falls back to scanning
177
- * `index.records` if an older-shaped index lacks that map.
206
+ * `index.records` if an older-shaped index lacks that map. A missing/junk
207
+ * index returns `[]` rather than throwing.
178
208
  */
179
209
  declare function listProvinces(index: TrigramIndex): ProvinceSummary[];
180
210
  /**
181
211
  * Amphures (districts) within a given province, deduplicated and sorted by
182
212
  * Thai name. Unknown `provinceId` returns `[]`. Backed by `index.byProvince`;
183
213
  * falls back to scanning `index.records` if an older-shaped index lacks it.
214
+ * A missing/junk index returns `[]` rather than throwing.
184
215
  */
185
216
  declare function listAmphures(index: TrigramIndex, provinceId: number): AmphureSummary[];
186
217
  /**
@@ -188,7 +219,8 @@ declare function listAmphures(index: TrigramIndex, provinceId: number): AmphureS
188
219
  * Unknown `amphureId` returns `[]`. Backed by `index.byAmphure`; falls back
189
220
  * to scanning `index.records` if an older-shaped index lacks it. Each
190
221
  * `byAmphure` entry already maps to exactly one tambon record, so no
191
- * deduplication is needed here.
222
+ * deduplication is needed here. A missing/junk index returns `[]` rather
223
+ * than throwing.
192
224
  */
193
225
  declare function listTambons(index: TrigramIndex, amphureId: number): TambonSummary[];
194
226
 
package/dist/index.js CHANGED
@@ -2,8 +2,13 @@
2
2
  var TONE_MARKS = /[่-๋็์]/g;
3
3
  var ADDRESS_PREFIXES = /^(จังหวัด|อำเภอ|ตำบล|แขวง|เขต|จ\.|อ\.|ต\.|ข\.)/;
4
4
  function normalizeThaiAddressText(input) {
5
- if (!input) return "";
6
- let text = input.trim();
5
+ if (typeof input !== "string") {
6
+ throw new TypeError(
7
+ `[thaizip] normalizeThaiAddressText expected string, got ${input === null ? "null" : typeof input}`
8
+ );
9
+ }
10
+ if (input.length === 0) return "";
11
+ let text = input.normalize("NFC").trim();
7
12
  text = text.replace(ADDRESS_PREFIXES, "").trim();
8
13
  text = text.replace(TONE_MARKS, "");
9
14
  text = text.toLowerCase();
@@ -31,24 +36,57 @@ function extractTrigramsNormalized(text) {
31
36
  function typeErr(table, id, field, expected, value) {
32
37
  return new TypeError(`[thaizip] ${table} ${id}: expected ${expected} for ${field}, got ${typeof value}`);
33
38
  }
39
+ function typeName(value) {
40
+ return value === null ? "null" : typeof value;
41
+ }
34
42
  function validateRawData(data) {
43
+ if (data === null || typeof data !== "object") {
44
+ throw new TypeError(`[thaizip] validateRawData expected a RawData object, got ${typeName(data)}`);
45
+ }
35
46
  const { provinces, amphures, tambons } = data;
47
+ if (!Array.isArray(provinces)) {
48
+ throw new TypeError(`[thaizip] RawData.provinces must be an array, got ${typeName(provinces)}`);
49
+ }
50
+ if (!Array.isArray(amphures)) {
51
+ throw new TypeError(`[thaizip] RawData.amphures must be an array, got ${typeName(amphures)}`);
52
+ }
53
+ if (!Array.isArray(tambons)) {
54
+ throw new TypeError(`[thaizip] RawData.tambons must be an array, got ${typeName(tambons)}`);
55
+ }
56
+ const seenProvinceIds = /* @__PURE__ */ new Set();
36
57
  for (let i = 0; i < provinces.length; i++) {
37
58
  const p = provinces[i];
59
+ if (p === null || typeof p !== "object") {
60
+ throw new TypeError(`[thaizip] province[${i}]: expected object, got ${typeName(p)}`);
61
+ }
38
62
  if (typeof p.id !== "number") throw typeErr("province", p.id, "id", "number", p.id);
63
+ if (seenProvinceIds.has(p.id)) throw new TypeError(`[thaizip] duplicate province id: ${p.id}`);
64
+ seenProvinceIds.add(p.id);
39
65
  if (typeof p.name_th !== "string") throw typeErr("province", p.id, "name_th", "string", p.name_th);
40
66
  if (typeof p.name_en !== "string") throw typeErr("province", p.id, "name_en", "string", p.name_en);
41
67
  }
68
+ const seenAmphureIds = /* @__PURE__ */ new Set();
42
69
  for (let i = 0; i < amphures.length; i++) {
43
70
  const a = amphures[i];
71
+ if (a === null || typeof a !== "object") {
72
+ throw new TypeError(`[thaizip] amphure[${i}]: expected object, got ${typeName(a)}`);
73
+ }
44
74
  if (typeof a.id !== "number") throw typeErr("amphure", a.id, "id", "number", a.id);
75
+ if (seenAmphureIds.has(a.id)) throw new TypeError(`[thaizip] duplicate amphure id: ${a.id}`);
76
+ seenAmphureIds.add(a.id);
45
77
  if (typeof a.name_th !== "string") throw typeErr("amphure", a.id, "name_th", "string", a.name_th);
46
78
  if (typeof a.name_en !== "string") throw typeErr("amphure", a.id, "name_en", "string", a.name_en);
47
79
  if (typeof a.province_id !== "number") throw typeErr("amphure", a.id, "province_id", "number", a.province_id);
48
80
  }
81
+ const seenTambonIds = /* @__PURE__ */ new Set();
49
82
  for (let i = 0; i < tambons.length; i++) {
50
83
  const t = tambons[i];
84
+ if (t === null || typeof t !== "object") {
85
+ throw new TypeError(`[thaizip] tambon[${i}]: expected object, got ${typeName(t)}`);
86
+ }
51
87
  if (typeof t.id !== "number") throw typeErr("tambon", t.id, "id", "number", t.id);
88
+ if (seenTambonIds.has(t.id)) throw new TypeError(`[thaizip] duplicate tambon id: ${t.id}`);
89
+ seenTambonIds.add(t.id);
52
90
  if (typeof t.name_th !== "string") throw typeErr("tambon", t.id, "name_th", "string", t.name_th);
53
91
  if (typeof t.name_en !== "string") throw typeErr("tambon", t.id, "name_en", "string", t.name_en);
54
92
  const zipType = typeof t.zip_code;
@@ -145,7 +183,9 @@ function buildThaiAddressIndex(data, options) {
145
183
  addTrigrams(map, provTrigrams.get(province.id), idx);
146
184
  addTrigrams(map, ampTrigrams.get(amphure.id), idx);
147
185
  }
148
- return { map, records, zipIndex, normTambon, normTambonEn, byProvince, byAmphure };
186
+ const sortedZipKeys = [...zipIndex.keys()].sort();
187
+ const sortedZipPostings = sortedZipKeys.map((z) => zipIndex.get(z));
188
+ return { map, records, zipIndex, sortedZipKeys, sortedZipPostings, normTambon, normTambonEn, byProvince, byAmphure };
149
189
  }
150
190
 
151
191
  // src/core/romanize.ts
@@ -266,27 +306,44 @@ function compareZip(a, b) {
266
306
  if (a > b) return 1;
267
307
  return 0;
268
308
  }
269
- function sortZipMatches(matches, exactZip) {
270
- matches.sort((a, b) => {
271
- if (a.zipCode === exactZip && b.zipCode !== exactZip) return -1;
272
- if (b.zipCode === exactZip && a.zipCode !== exactZip) return 1;
273
- return compareZip(a.zipCode, b.zipCode);
274
- });
275
- return matches;
309
+ function normalizeLimit(value) {
310
+ if (Number.isNaN(value) || value === -Infinity) return 0;
311
+ if (value === Infinity) return value;
312
+ return Math.max(0, Math.floor(value));
276
313
  }
277
314
  function lookupByZipCode(index, zip, options) {
278
- if (!index || !zip) return [];
315
+ if (!index || !index.zipIndex || !index.records || typeof zip !== "string") return [];
316
+ if (zip.length > 1e3) return [];
317
+ const zipLimit = normalizeLimit(options?.zipLimit ?? Infinity);
318
+ if (zipLimit === 0) return [];
279
319
  const normalized = zip.trim();
280
320
  if (!ZIP_CODE_RE.test(normalized) || normalized.length < 2) return [];
281
- const zipLimit = options?.zipLimit ?? Infinity;
282
321
  const matches = [];
283
- for (const [z, indices] of index.zipIndex) {
284
- if (z.startsWith(normalized)) {
285
- for (const idx of indices) matches.push(index.records[idx]);
322
+ const keys = index.sortedZipKeys;
323
+ const postings = index.sortedZipPostings;
324
+ if (keys !== void 0 && postings !== void 0) {
325
+ let lo = 0;
326
+ let hi = keys.length;
327
+ while (lo < hi) {
328
+ const mid = lo + hi >> 1;
329
+ if (keys[mid] < normalized) lo = mid + 1;
330
+ else hi = mid;
331
+ }
332
+ for (let i = lo; i < keys.length && keys[i].startsWith(normalized) && matches.length < zipLimit; i++) {
333
+ const indices = postings[i];
334
+ for (let j = 0; j < indices.length && matches.length < zipLimit; j++) matches.push(index.records[indices[j]]);
335
+ }
336
+ } else {
337
+ const matchingKeys = [...index.zipIndex.keys()].filter((z) => z.startsWith(normalized)).sort(compareZip);
338
+ for (const z of matchingKeys) {
339
+ if (matches.length >= zipLimit) break;
340
+ for (const idx of index.zipIndex.get(z)) {
341
+ if (matches.length >= zipLimit) break;
342
+ matches.push(index.records[idx]);
343
+ }
286
344
  }
287
345
  }
288
- sortZipMatches(matches, normalized);
289
- return matches.slice(0, zipLimit);
346
+ return matches.slice(0, zipLimit).map((record) => ({ ...record }));
290
347
  }
291
348
  function rankAgainstName(name, query) {
292
349
  if (name === void 0 || name.length === 0) return 0;
@@ -301,10 +358,20 @@ function computeMatchRank(index, idx, query) {
301
358
  const en = rankAgainstName(index.normTambonEn?.[idx], query);
302
359
  return en > th ? en : th;
303
360
  }
361
+ var scratchByIndex = /* @__PURE__ */ new WeakMap();
362
+ function getScratch(index) {
363
+ const n = index.records.length;
364
+ let s = scratchByIndex.get(index);
365
+ if (s === void 0 || s.counts.length < n) {
366
+ s = { counts: new Uint32Array(n), touched: new Int32Array(n) };
367
+ scratchByIndex.set(index, s);
368
+ }
369
+ return s;
370
+ }
304
371
  function searchThaiAddress(index, query, options) {
305
- const limit = options?.limit ?? 10;
372
+ const limit = normalizeLimit(options?.limit ?? 10);
306
373
  const threshold = options?.threshold ?? 0.4;
307
- if (!index || !query) return [];
374
+ if (!index || !index.map || !index.records || typeof query !== "string") return [];
308
375
  if (query.length > 1e3) return [];
309
376
  const normalized = normalizeThaiAddressText(query);
310
377
  if (normalized.length === 0) return [];
@@ -316,19 +383,25 @@ function searchThaiAddress(index, query, options) {
316
383
  });
317
384
  }
318
385
  if (normalized.length < 3) return [];
386
+ if (limit === 0) return [];
319
387
  const searchText = options?.romanizationAliases !== false && HAS_LATIN_RE.test(normalized) ? applyRomanizationAliases(normalized) : normalized;
320
388
  const queryTrigrams = extractTrigramsNormalized(searchText);
321
- const hits = /* @__PURE__ */ new Map();
389
+ const { counts, touched } = getScratch(index);
390
+ let touchedLen = 0;
322
391
  for (const trigram of queryTrigrams) {
323
392
  const candidates = index.map.get(trigram);
324
393
  if (!candidates) continue;
325
394
  for (const idx of candidates) {
326
- hits.set(idx, (hits.get(idx) ?? 0) + 1);
395
+ if (counts[idx] === 0) touched[touchedLen++] = idx;
396
+ counts[idx]++;
327
397
  }
328
398
  }
329
399
  const scored = [];
330
- for (const [idx, count] of hits) {
331
- const score = count / queryTrigrams.size;
400
+ const totalTrigrams = queryTrigrams.size;
401
+ for (let i = 0; i < touchedLen; i++) {
402
+ const idx = touched[i];
403
+ const score = counts[idx] / totalTrigrams;
404
+ counts[idx] = 0;
332
405
  if (score >= threshold) {
333
406
  scored.push({ idx, score, matchRank: computeMatchRank(index, idx, searchText) });
334
407
  }
@@ -337,7 +410,11 @@ function searchThaiAddress(index, query, options) {
337
410
  if (b.score !== a.score) return b.score - a.score;
338
411
  return b.matchRank - a.matchRank;
339
412
  });
340
- const windowSize = Math.max(limit * 4, 50);
413
+ let windowSize = Math.min(limit, scored.length);
414
+ const boundary = scored[windowSize - 1];
415
+ while (windowSize < scored.length && scored[windowSize].score === boundary.score && scored[windowSize].matchRank === boundary.matchRank) {
416
+ windowSize++;
417
+ }
341
418
  const window = scored.slice(0, windowSize);
342
419
  window.sort((a, b) => {
343
420
  if (b.score !== a.score) return b.score - a.score;
@@ -346,7 +423,7 @@ function searchThaiAddress(index, query, options) {
346
423
  const rb = index.records[b.idx];
347
424
  return TH_COLLATOR.compare(ra.provinceNameTh, rb.provinceNameTh) || TH_COLLATOR.compare(ra.amphureNameTh, rb.amphureNameTh) || TH_COLLATOR.compare(ra.tambonNameTh, rb.tambonNameTh);
348
425
  });
349
- return window.slice(0, limit).map(({ idx }) => index.records[idx]);
426
+ return window.slice(0, limit).map(({ idx }) => ({ ...index.records[idx] }));
350
427
  }
351
428
 
352
429
  // src/core/enumerate.ts
@@ -355,6 +432,7 @@ function byNameTh(a, b) {
355
432
  return thCollator.compare(a.nameTh, b.nameTh);
356
433
  }
357
434
  function listProvinces(index) {
435
+ if (!index || !index.records) return [];
358
436
  const seen = /* @__PURE__ */ new Map();
359
437
  if (index.byProvince) {
360
438
  for (const [provinceId, recordIndices] of index.byProvince) {
@@ -372,6 +450,7 @@ function listProvinces(index) {
372
450
  return Array.from(seen.values()).sort(byNameTh);
373
451
  }
374
452
  function listAmphures(index, provinceId) {
453
+ if (!index || !index.records) return [];
375
454
  const seen = /* @__PURE__ */ new Map();
376
455
  const addFrom = (r) => {
377
456
  if (!seen.has(r.amphureId)) {
@@ -395,6 +474,7 @@ function listAmphures(index, provinceId) {
395
474
  return Array.from(seen.values()).sort(byNameTh);
396
475
  }
397
476
  function listTambons(index, amphureId) {
477
+ if (!index || !index.records) return [];
398
478
  const toSummary = (r) => ({
399
479
  id: r.tambonId,
400
480
  nameTh: r.tambonNameTh,