@hozon/adapter 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Paul Le Cam
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,17 @@
1
- # Temporary Holding Version
1
+ # @hozon/adapter
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Shared adapter contract and dialect helpers for Hozon's SQLite and PostgreSQL drivers.
4
+
5
+ ```sh
6
+ pnpm add @hozon/adapter
7
+ ```
8
+
9
+ Import `Adapter` when defining a driver contract:
10
+
11
+ ```ts
12
+ import type { Adapter } from '@hozon/adapter'
13
+
14
+ type DriverContract = Adapter
15
+ ```
16
+
17
+ See the [adapter reference](../../docs/reference/adapter.md).
package/lib/index.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ export { AbstractPostgresAdapter, type PostgresTypes } from './postgres.js';
2
+ export { AbstractSQLiteAdapter, type SQLiteTypes } from './sqlite.js';
3
+ export type { AbstractAdapter, Adapter, AdapterTypes, ArrayPresenceMode, ColumnTypes, CreatedAtColumn, Functions, NullOrderingTerm, OrderDirection, UpdatedAtColumn, } from './types.js';
package/lib/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { AbstractPostgresAdapter } from './postgres.js';
2
+ export { AbstractSQLiteAdapter } from './sqlite.js';
@@ -0,0 +1,23 @@
1
+ import type { Expression, RawBuilder } from 'kysely';
2
+ import type { AbstractAdapter, AdapterTypes, ArrayPresenceMode, ColumnTypes, Functions, NullOrderingTerm, OrderDirection } from './types.js';
3
+ export type PostgresTypes = {
4
+ Binary: Uint8Array;
5
+ JSON: unknown;
6
+ Timestamp: string;
7
+ };
8
+ export declare abstract class AbstractPostgresAdapter<T extends AdapterTypes = PostgresTypes> implements AbstractAdapter<T> {
9
+ kind: "postgres";
10
+ functions: Functions;
11
+ types: ColumnTypes;
12
+ encodeBinary(value: Uint8Array): Uint8Array<ArrayBufferLike>;
13
+ encodeJSON(value: unknown): unknown;
14
+ encodeTimestamp(value: Date): string;
15
+ decodeTimestamp(value: string): Date;
16
+ coerceFilterValue(value: unknown): unknown;
17
+ numericCast(expression: Expression<unknown>): Expression<number>;
18
+ nullOrdering(_expression: Expression<unknown>, _direction: OrderDirection): NullOrderingTerm;
19
+ containsPredicate(expression: Expression<unknown>, pattern: string): RawBuilder<boolean>;
20
+ arrayIncludesAllPredicate(expression: Expression<unknown>, values: Array<unknown>): RawBuilder<boolean>;
21
+ arrayIncludesAnyPredicate(expression: Expression<unknown>, values: Array<unknown>): RawBuilder<boolean>;
22
+ arrayPresencePredicate(expression: Expression<unknown>, mode: ArrayPresenceMode): RawBuilder<boolean>;
23
+ }
@@ -0,0 +1,87 @@
1
+ import { sql } from 'kysely';
2
+ export class AbstractPostgresAdapter {
3
+ kind = 'postgres';
4
+ functions = {
5
+ now: 'now()'
6
+ };
7
+ types = {
8
+ bigint: 'bigint',
9
+ boolean: 'boolean',
10
+ double: 'double precision',
11
+ binary: 'bytea',
12
+ json: 'jsonb',
13
+ serial: 'serial',
14
+ text: 'text',
15
+ // Use 3 digits for timestamps to have millisecond precision, same as JS dates
16
+ timestamp: 'timestamptz(3)',
17
+ uuid: 'uuid'
18
+ };
19
+ encodeBinary(value) {
20
+ return value;
21
+ }
22
+ encodeJSON(value) {
23
+ return value;
24
+ }
25
+ encodeTimestamp(value) {
26
+ return value.toISOString();
27
+ }
28
+ decodeTimestamp(value) {
29
+ return new Date(value);
30
+ }
31
+ coerceFilterValue(value) {
32
+ if (typeof value === 'boolean') {
33
+ return value ? 'true' : 'false';
34
+ }
35
+ return value;
36
+ }
37
+ numericCast(expression) {
38
+ return sql`(${expression})::double precision`;
39
+ }
40
+ nullOrdering(_expression, _direction) {
41
+ // Postgres applies SQL-standard null position natively (NULLS LAST on asc, NULLS
42
+ // FIRST on desc) via the dialect's own clause on the field's order-by item.
43
+ return {
44
+ kind: 'native'
45
+ };
46
+ }
47
+ containsPredicate(expression, pattern) {
48
+ return sql`${expression} ILIKE ${pattern} ESCAPE '\\'`;
49
+ }
50
+ arrayIncludesAllPredicate(expression, values) {
51
+ // Native jsonb containment: works for string and numeric elements alike. NULL node
52
+ // coalesces to an empty array, which is a subset only of another empty set.
53
+ // Cast via ::text::jsonb, not a direct ::jsonb: postgres.js infers the parameter's
54
+ // wire type from a trailing ::jsonb cast and re-encodes the JS string as a jsonb
55
+ // *string scalar* instead of sending it as text for the server to parse, so a
56
+ // direct ::jsonb cast silently never matches. The ::text hop forces text encoding.
57
+ // Duplicates are ignored and an empty list is vacuously true (every array, NULL
58
+ // included, contains the empty set) — identical on every backend.
59
+ const distinct = Array.from(new Set(values));
60
+ if (distinct.length === 0) return sql`1 = 1`;
61
+ return sql`COALESCE(${expression}, '[]'::jsonb) @> ${JSON.stringify(distinct)}::text::jsonb`;
62
+ }
63
+ arrayIncludesAnyPredicate(expression, values) {
64
+ // An empty list matches nothing; never emit `IN ()`, which Postgres rejects.
65
+ if (values.length === 0) return sql`1 = 0`;
66
+ // Elements, not `?|`, so numeric arrays are covered too (`?|` only matches string keys).
67
+ return sql`EXISTS (SELECT 1 FROM jsonb_array_elements_text(COALESCE(${expression}, '[]'::jsonb)) AS _t WHERE _t IN (${sql.join(values.map((v)=>sql`${v}`))}))`;
68
+ }
69
+ arrayPresencePredicate(expression, mode) {
70
+ // null/nonNull/empty/nonEmpty must see the true NULL, so the node is never coalesced
71
+ // there — jsonb_array_length(NULL) is NULL, and NULL > 0 is not-true (excludes it).
72
+ switch(mode){
73
+ case 'null':
74
+ return sql`${expression} IS NULL`;
75
+ case 'nonNull':
76
+ return sql`${expression} IS NOT NULL`;
77
+ case 'empty':
78
+ return sql`jsonb_array_length(${expression}) = 0`;
79
+ case 'nonEmpty':
80
+ return sql`jsonb_array_length(${expression}) > 0`;
81
+ case 'nullOrEmpty':
82
+ return sql`COALESCE(jsonb_array_length(${expression}), 0) = 0`;
83
+ default:
84
+ throw new Error(`Invalid array presence mode: ${mode}`);
85
+ }
86
+ }
87
+ }
@@ -0,0 +1,23 @@
1
+ import type { Expression, RawBuilder } from 'kysely';
2
+ import type { AbstractAdapter, AdapterTypes, ArrayPresenceMode, ColumnTypes, Functions, NullOrderingTerm, OrderDirection } from './types.js';
3
+ export type SQLiteTypes = {
4
+ Binary: Uint8Array;
5
+ JSON: string;
6
+ Timestamp: number;
7
+ };
8
+ export declare abstract class AbstractSQLiteAdapter<T extends AdapterTypes = SQLiteTypes> implements AbstractAdapter<T> {
9
+ kind: "sqlite";
10
+ functions: Functions;
11
+ types: ColumnTypes;
12
+ encodeBinary(value: Uint8Array): Uint8Array<ArrayBufferLike>;
13
+ encodeJSON(value: unknown): string;
14
+ encodeTimestamp(value: Date): number;
15
+ decodeTimestamp(value: number): Date;
16
+ coerceFilterValue(value: unknown): unknown;
17
+ numericCast(expression: Expression<unknown>): Expression<number>;
18
+ nullOrdering(expression: Expression<unknown>, direction: OrderDirection): NullOrderingTerm;
19
+ containsPredicate(expression: Expression<unknown>, pattern: string): RawBuilder<boolean>;
20
+ arrayIncludesAllPredicate(expression: Expression<unknown>, values: Array<unknown>): RawBuilder<boolean>;
21
+ arrayIncludesAnyPredicate(expression: Expression<unknown>, values: Array<unknown>): RawBuilder<boolean>;
22
+ arrayPresencePredicate(expression: Expression<unknown>, mode: ArrayPresenceMode): RawBuilder<boolean>;
23
+ }
package/lib/sqlite.js ADDED
@@ -0,0 +1,87 @@
1
+ import { sql } from 'kysely';
2
+ export class AbstractSQLiteAdapter {
3
+ kind = 'sqlite';
4
+ functions = {
5
+ now: sql`(unixepoch())`
6
+ };
7
+ types = {
8
+ bigint: 'integer',
9
+ boolean: 'integer',
10
+ double: 'real',
11
+ binary: 'blob',
12
+ json: 'jsonb',
13
+ serial: 'integer',
14
+ text: 'text',
15
+ timestamp: 'integer',
16
+ uuid: 'text'
17
+ };
18
+ encodeBinary(value) {
19
+ return value;
20
+ }
21
+ encodeJSON(value) {
22
+ return JSON.stringify(value);
23
+ }
24
+ encodeTimestamp(value) {
25
+ // SQLite timestamps are stored in seconds
26
+ return Math.floor(value.getTime() / 1000);
27
+ }
28
+ decodeTimestamp(value) {
29
+ // SQLite timestamps are stored in seconds
30
+ return new Date(value * 1000);
31
+ }
32
+ coerceFilterValue(value) {
33
+ if (typeof value === 'boolean') {
34
+ return value ? 1 : 0;
35
+ }
36
+ return value;
37
+ }
38
+ numericCast(expression) {
39
+ return sql`CAST(${expression} AS REAL)`;
40
+ }
41
+ nullOrdering(expression, direction) {
42
+ // SQLite has no NULLS LAST/FIRST clause, so prepend an ascending rank column that
43
+ // buckets nulls last on ascending (rank 1 > 0) and first on descending (rank 0 < 1).
44
+ const nullRank = direction === 'asc' ? 1 : 0;
45
+ const valueRank = direction === 'asc' ? 0 : 1;
46
+ return {
47
+ kind: 'lead',
48
+ expression: sql`CASE WHEN ${expression} IS NULL THEN ${sql.lit(nullRank)} ELSE ${sql.lit(valueRank)} END`
49
+ };
50
+ }
51
+ containsPredicate(expression, pattern) {
52
+ // SQLite's LIKE is already ASCII case-insensitive by default.
53
+ return sql`${expression} LIKE ${pattern} ESCAPE '\\'`;
54
+ }
55
+ arrayIncludesAllPredicate(expression, values) {
56
+ // Duplicates are ignored (the count compares against the distinct set size) and an
57
+ // empty list is vacuously true — identical on every backend.
58
+ const distinct = Array.from(new Set(values));
59
+ if (distinct.length === 0) return sql`1 = 1`;
60
+ // NULL/absent node -> json_each yields zero rows -> COUNT(DISTINCT value) = 0, never
61
+ // equal to a positive set size.
62
+ return sql`(SELECT COUNT(DISTINCT value) FROM json_each(COALESCE(${expression}, '[]')) WHERE value IN (${sql.join(distinct.map((v)=>sql`${v}`))})) = ${distinct.length}`;
63
+ }
64
+ arrayIncludesAnyPredicate(expression, values) {
65
+ // An empty list matches nothing; never emit `IN ()` (portable with Postgres).
66
+ if (values.length === 0) return sql`1 = 0`;
67
+ return sql`EXISTS (SELECT 1 FROM json_each(COALESCE(${expression}, '[]')) WHERE value IN (${sql.join(values.map((v)=>sql`${v}`))}))`;
68
+ }
69
+ arrayPresencePredicate(expression, mode) {
70
+ // null/nonNull/empty/nonEmpty must see the true NULL, so the node is never coalesced
71
+ // there — json_array_length(NULL) is NULL, and NULL > 0 is not-true (excludes it).
72
+ switch(mode){
73
+ case 'null':
74
+ return sql`${expression} IS NULL`;
75
+ case 'nonNull':
76
+ return sql`${expression} IS NOT NULL`;
77
+ case 'empty':
78
+ return sql`json_array_length(${expression}) = 0`;
79
+ case 'nonEmpty':
80
+ return sql`json_array_length(${expression}) > 0`;
81
+ case 'nullOrEmpty':
82
+ return sql`COALESCE(json_array_length(${expression}), 0) = 0`;
83
+ default:
84
+ throw new Error(`Invalid array presence mode: ${mode}`);
85
+ }
86
+ }
87
+ }
package/lib/types.d.ts ADDED
@@ -0,0 +1,51 @@
1
+ import type { ColumnDataType, ColumnType, Dialect, Expression, RawBuilder } from 'kysely';
2
+ export type OrderDirection = 'asc' | 'desc';
3
+ export type ArrayPresenceMode = 'null' | 'nonNull' | 'empty' | 'nonEmpty' | 'nullOrEmpty';
4
+ export type NullOrderingTerm = {
5
+ kind: 'lead';
6
+ expression: Expression<number>;
7
+ } | {
8
+ kind: 'native';
9
+ };
10
+ export type CreatedAtColumn = ColumnType<number, number | undefined, never>;
11
+ export type UpdatedAtColumn = ColumnType<number, number | undefined, number | undefined>;
12
+ export type ColumnTypes = {
13
+ bigint: ColumnDataType;
14
+ binary: ColumnDataType;
15
+ boolean: ColumnDataType;
16
+ double: ColumnDataType;
17
+ json: ColumnDataType;
18
+ serial: ColumnDataType;
19
+ text: ColumnDataType;
20
+ timestamp: ColumnDataType;
21
+ uuid: ColumnDataType;
22
+ };
23
+ export type Functions = {
24
+ now: string | RawBuilder<string>;
25
+ };
26
+ export type AdapterTypes = {
27
+ Binary: unknown;
28
+ JSON: unknown;
29
+ Timestamp: unknown;
30
+ };
31
+ export type AbstractAdapter<T extends AdapterTypes = AdapterTypes> = {
32
+ kind: 'sqlite' | 'postgres';
33
+ functions: Functions;
34
+ types: ColumnTypes;
35
+ encodeBinary(value: Uint8Array): T['Binary'];
36
+ encodeJSON(value: unknown): T['JSON'];
37
+ encodeTimestamp(value: Date): T['Timestamp'];
38
+ decodeTimestamp(value: T['Timestamp']): Date;
39
+ coerceFilterValue(value: unknown): unknown;
40
+ numericCast(expression: Expression<unknown>): Expression<number>;
41
+ nullOrdering(expression: Expression<unknown>, direction: OrderDirection): NullOrderingTerm;
42
+ containsPredicate(expression: Expression<unknown>, pattern: string): RawBuilder<boolean>;
43
+ arrayIncludesAllPredicate(expression: Expression<unknown>, values: Array<unknown>): RawBuilder<boolean>;
44
+ arrayIncludesAnyPredicate(expression: Expression<unknown>, values: Array<unknown>): RawBuilder<boolean>;
45
+ arrayPresencePredicate(expression: Expression<unknown>, mode: ArrayPresenceMode): RawBuilder<boolean>;
46
+ prepare?(): Promise<void>;
47
+ close?(): Promise<void>;
48
+ };
49
+ export type Adapter<T extends AdapterTypes = AdapterTypes> = AbstractAdapter<T> & {
50
+ dialect: Dialect;
51
+ };
package/lib/types.js ADDED
@@ -0,0 +1 @@
1
+ export { };
package/package.json CHANGED
@@ -1,6 +1,42 @@
1
1
  {
2
2
  "name": "@hozon/adapter",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "Database adapter interfaces and dialect behavior for Hozon",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/TairuFramework/hozon.git",
8
+ "directory": "packages/adapter"
9
+ },
10
+ "license": "MIT",
11
+ "sideEffects": false,
12
+ "type": "module",
13
+ "exports": {
14
+ ".": "./lib/index.js"
15
+ },
16
+ "files": [
17
+ "lib/*"
18
+ ],
19
+ "dependencies": {
20
+ "kysely": "^0.29.6"
21
+ },
22
+ "devDependencies": {
23
+ "del-cli": "^7.0.0",
24
+ "vitest": "5.0.3"
25
+ },
26
+ "engines": {
27
+ "node": ">=24"
28
+ },
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "scripts": {
33
+ "build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
34
+ "build:clean": "del-cli lib",
35
+ "build:js": "del-cli \"lib/**/*.js\" && swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
36
+ "build:types": "del-cli \"lib/**/*.d.ts\" \"lib/**/*.d.ts.map\" && tsc --emitDeclarationOnly --skipLibCheck",
37
+ "build:types:ci": "tsc --emitDeclarationOnly --skipLibCheck --declarationMap false",
38
+ "test": "pnpm run test:types && pnpm run test:unit",
39
+ "test:types": "tsc --noEmit --skipLibCheck -p tsconfig.test.json",
40
+ "test:unit": "vitest run"
41
+ }
6
42
  }