@promakeai/orm 1.0.1 → 1.0.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/README.md CHANGED
@@ -13,9 +13,9 @@ npm install @promakeai/orm
13
13
  ```typescript
14
14
  import { defineSchema, f } from '@promakeai/orm';
15
15
 
16
- const schema = defineSchema({
17
- name: 'myapp',
18
- languages: ['en', 'tr', 'de'],
16
+ const schema = defineSchema({
17
+ name: 'myapp',
18
+ languages: ['en', 'tr', 'de'],
19
19
  tables: {
20
20
  users: {
21
21
  id: f.id(),
@@ -47,21 +47,45 @@ const schema = defineSchema({
47
47
  }),
48
48
  },
49
49
  },
50
- });
51
- ```
52
-
53
- ## Field Types
54
-
55
- | Type | SQL | Description |
56
- |------|-----|-------------|
57
- | `f.id()` | INTEGER PRIMARY KEY AUTOINCREMENT | Auto-increment primary key |
58
- | `f.string()` | VARCHAR | Short text |
59
- | `f.text()` | TEXT | Long text |
60
- | `f.int()` | INTEGER | Integer |
61
- | `f.decimal()` | REAL/DECIMAL | Decimal number |
62
- | `f.bool()` | INTEGER (0/1) | Boolean |
63
- | `f.timestamp()` | TEXT (ISO) | ISO datetime string |
64
- | `f.json()` | TEXT | JSON serialized data |
50
+ });
51
+ ```
52
+
53
+ ## JSON Schema (AI-Friendly)
54
+
55
+ The JSON schema format is the runtime-friendly version and supports native
56
+ arrays and typed objects:
57
+
58
+ ```json
59
+ {
60
+ "name": "myapp",
61
+ "languages": ["en", "tr"],
62
+ "defaultLanguage": "en",
63
+ "tables": {
64
+ "products": {
65
+ "id": { "type": "id" },
66
+ "tags": { "type": ["string"] },
67
+ "metadata": { "type": { "color": "string", "weight": "number" } },
68
+ "variants": { "type": [{ "sku": "string", "price": "number" }] }
69
+ }
70
+ }
71
+ }
72
+ ```
73
+
74
+ ## Field Types
75
+
76
+ | Type | SQL | Description |
77
+ |------|-----|-------------|
78
+ | `f.id()` | INTEGER PRIMARY KEY AUTOINCREMENT | Auto-increment primary key |
79
+ | `f.string()` | VARCHAR | Short text |
80
+ | `f.text()` | TEXT | Long text |
81
+ | `f.int()` | INTEGER | Integer |
82
+ | `f.decimal()` | REAL/DECIMAL | Decimal number |
83
+ | `f.bool()` | INTEGER (0/1) | Boolean |
84
+ | `f.timestamp()` | TEXT (ISO) | ISO datetime string |
85
+ | `f.json()` | TEXT | JSON serialized data |
86
+
87
+ JSON schema type syntax supports `string[]`, `number[]`, `boolean[]`, `object`,
88
+ and `object[]` which are stored as JSON in SQL (TEXT) and typed in TS.
65
89
 
66
90
  ## Field Modifiers
67
91
 
@@ -148,10 +172,14 @@ buildWhereClause({ id: { $nin: [1, 2, 3] } }); // NOT IN (?, ?, ?)
148
172
  buildWhereClause({ name: { $like: '%john%' } }); // LIKE ?
149
173
  buildWhereClause({ name: { $notLike: '%test%' } }); // NOT LIKE ?
150
174
 
151
- // Range and null
152
- buildWhereClause({ price: { $between: [10, 100] } }); // BETWEEN ? AND ?
153
- buildWhereClause({ deletedAt: { $isNull: true } }); // IS NULL
154
- buildWhereClause({ email: { $isNull: false } }); // IS NOT NULL
175
+ // Range and null
176
+ buildWhereClause({ price: { $between: [10, 100] } }); // BETWEEN ? AND ?
177
+ buildWhereClause({ deletedAt: { $isNull: true } }); // IS NULL
178
+ buildWhereClause({ email: { $isNull: false } }); // IS NOT NULL
179
+
180
+ // JSON array contains
181
+ buildWhereClause({ tags: { $contains: "sale" } }); // json_each(...) = "sale"
182
+ buildWhereClause({ tags: { $containsAny: ["sale", "new"] } }); // json_each(...) IN (...)
155
183
 
156
184
  // Logical operators
157
185
  buildWhereClause({
@@ -262,9 +290,12 @@ const { sql, params } = buildTranslationQuery('products', schema, {
262
290
  // LIMIT 10
263
291
  ```
264
292
 
265
- ## Populate Resolver
266
-
267
- Batch-fetches references to prevent N+1 queries:
293
+ ## Populate Resolver
294
+
295
+ Batch-fetches references to prevent N+1 queries:
296
+
297
+ Populate options accept a space-separated string, string array, or nested object
298
+ for deeper population.
268
299
 
269
300
  ```typescript
270
301
  import { resolvePopulate } from '@promakeai/orm';
package/dist/index.d.ts CHANGED
@@ -47,5 +47,6 @@ export type { TranslationQueryOptions, TranslationQueryResult, } from "./utils/t
47
47
  export { resolvePopulate, getPopulatableFields, validatePopulate, } from "./utils/populateResolver";
48
48
  export type { PopulateAdapter } from "./utils/populateResolver";
49
49
  export { parseJSONSchema } from "./utils/jsonConverter";
50
+ export { isJsonType } from "./types";
50
51
  export { deserializeRow, serializeRow } from "./utils/deserializer";
51
- export type { FieldType, FieldDefinition, FieldReference, FieldBuilderLike, TableDefinition, LanguageConfig, SchemaDefinition, SchemaInput, JSONFieldDefinition, JSONTableDefinition, JSONSchemaDefinition, WhereCondition, OrderByOption, PopulateOption, PopulateNested, QueryOptions, PaginatedResult, ORMConfig, } from "./types";
52
+ export type { FieldType, FieldDefinition, FieldReference, FieldBuilderLike, TableDefinition, LanguageConfig, SchemaDefinition, SchemaInput, JSONFieldType, JSONFieldDefinition, JSONTableDefinition, JSONSchemaDefinition, WhereCondition, OrderByOption, PopulateOption, PopulateNested, QueryOptions, PaginatedResult, ORMConfig, } from "./types";
package/dist/index.js CHANGED
@@ -1,3 +1,8 @@
1
+ // src/types.ts
2
+ function isJsonType(type) {
3
+ return type === "json" || type === "string[]" || type === "number[]" || type === "boolean[]" || type === "object" || type === "object[]";
4
+ }
5
+
1
6
  // src/utils/populateResolver.ts
2
7
  function getRefInfo(fieldName, field) {
3
8
  if (!field.ref)
@@ -9,7 +14,7 @@ function getRefInfo(fieldName, field) {
9
14
  field: isString ? "id" : ref.field || "id",
10
15
  localField: fieldName,
11
16
  targetField: isString ? "id" : ref.field || "id",
12
- isArray: Array.isArray(field.default) || fieldName.endsWith("Ids")
17
+ isArray: isJsonType(field.type) || Array.isArray(field.default) || fieldName.endsWith("Ids")
13
18
  };
14
19
  }
15
20
  function toPopulatedName(fieldName) {
@@ -164,7 +169,10 @@ class ORM {
164
169
  const { populate, ...queryOpts } = opts;
165
170
  let records = await this.adapter.list(table, queryOpts);
166
171
  if (populate && this.schema) {
167
- records = await this.resolvePopulateForRecords(records, table, populate);
172
+ records = await this.resolvePopulateForRecords(records, table, populate, {
173
+ lang: opts.lang,
174
+ fallbackLang: opts.fallbackLang
175
+ });
168
176
  }
169
177
  return records;
170
178
  }
@@ -173,7 +181,7 @@ class ORM {
173
181
  const { populate, ...queryOpts } = opts;
174
182
  const record = await this.adapter.get(table, id, queryOpts);
175
183
  if (record && populate && this.schema) {
176
- const [populated] = await this.resolvePopulateForRecords([record], table, populate);
184
+ const [populated] = await this.resolvePopulateForRecords([record], table, populate, { lang: opts.lang, fallbackLang: opts.fallbackLang });
177
185
  return populated;
178
186
  }
179
187
  return record;
@@ -257,7 +265,7 @@ class ORM {
257
265
  throw new Error(`Table "${table}" not found in schema`);
258
266
  }
259
267
  }
260
- async resolvePopulateForRecords(records, table, populate) {
268
+ async resolvePopulateForRecords(records, table, populate, queryOptions) {
261
269
  if (!this.schema)
262
270
  return records;
263
271
  const validation = validatePopulate(populate, table, this.schema);
@@ -266,7 +274,11 @@ class ORM {
266
274
  }
267
275
  const adapterWrapper = {
268
276
  findMany: (t, opts) => {
269
- return this.adapter.list(t, opts);
277
+ return this.adapter.list(t, {
278
+ ...opts,
279
+ lang: queryOptions?.lang,
280
+ fallbackLang: queryOptions?.fallbackLang
281
+ });
270
282
  }
271
283
  };
272
284
  return resolvePopulate(records, table, populate, this.schema, adapterWrapper);
@@ -299,7 +311,7 @@ function getPrimaryKeyField(table) {
299
311
  return entry ? entry[0] : null;
300
312
  }
301
313
  function getReferenceFields(table) {
302
- return Object.entries(table.fields).filter(([_, field]) => field.ref).map(([name, field]) => {
314
+ return Object.entries(table.fields).filter(([_, field]) => field.ref && !isJsonType(field.type)).map(([name, field]) => {
303
315
  const ref = field.ref;
304
316
  if (typeof ref === "string") {
305
317
  return [name, { table: ref, field: "id" }];
@@ -536,7 +548,20 @@ var f = {
536
548
  decimal: () => new FieldBuilder("decimal"),
537
549
  bool: () => new FieldBuilder("bool"),
538
550
  timestamp: () => new FieldBuilder("timestamp"),
539
- json: () => new FieldBuilder("json")
551
+ json: () => new FieldBuilder("json"),
552
+ stringArray: () => new FieldBuilder("string[]"),
553
+ numberArray: () => new FieldBuilder("number[]"),
554
+ boolArray: () => new FieldBuilder("boolean[]"),
555
+ object: (properties) => {
556
+ const builder = new FieldBuilder("object");
557
+ builder["definition"].properties = properties;
558
+ return builder;
559
+ },
560
+ objectArray: (properties) => {
561
+ const builder = new FieldBuilder("object[]");
562
+ builder["definition"].properties = properties;
563
+ return builder;
564
+ }
540
565
  };
541
566
 
542
567
  // src/schema/defineSchema.ts
@@ -740,6 +765,20 @@ function processFieldOperators(field, operators) {
740
765
  params.push(value[0], value[1]);
741
766
  } else if (op === "$isNull") {
742
767
  parts.push(value ? `${field} IS NULL` : `${field} IS NOT NULL`);
768
+ } else if (op === "$contains") {
769
+ parts.push(`EXISTS (SELECT 1 FROM json_each(${field}) WHERE value = ?)`);
770
+ params.push(value);
771
+ } else if (op === "$containsAny") {
772
+ if (!Array.isArray(value)) {
773
+ throw new Error("$containsAny operator requires an array value");
774
+ }
775
+ if (value.length === 0) {
776
+ parts.push("0 = 1");
777
+ } else {
778
+ const placeholders = value.map(() => "?").join(", ");
779
+ parts.push(`EXISTS (SELECT 1 FROM json_each(${field}) WHERE value IN (${placeholders}))`);
780
+ params.push(...value);
781
+ }
743
782
  } else if (op === "$not") {
744
783
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
745
784
  throw new Error("$not operator requires an object with operators");
@@ -813,6 +852,7 @@ function buildTranslationQuery(options) {
813
852
  schema,
814
853
  lang,
815
854
  fallbackLang = schema.languages.default,
855
+ mainFallbackFields,
816
856
  where,
817
857
  orderBy,
818
858
  limit,
@@ -831,10 +871,15 @@ function buildTranslationQuery(options) {
831
871
  const mainAlias = "m";
832
872
  const transAlias = "t";
833
873
  const fallbackAlias = "fb";
874
+ const mainFallbackSet = mainFallbackFields !== undefined ? new Set(mainFallbackFields) : null;
834
875
  const selectFields = [];
835
876
  for (const [fieldName, field] of Object.entries(tableSchema.fields)) {
836
877
  if (field.translatable) {
837
- selectFields.push(`COALESCE(${transAlias}.${fieldName}, ${fallbackAlias}.${fieldName}) as ${fieldName}`);
878
+ if (mainFallbackSet && !mainFallbackSet.has(fieldName)) {
879
+ selectFields.push(`COALESCE(${transAlias}.${fieldName}, ${fallbackAlias}.${fieldName}) as ${fieldName}`);
880
+ } else {
881
+ selectFields.push(`COALESCE(${transAlias}.${fieldName}, ${fallbackAlias}.${fieldName}, ${mainAlias}.${fieldName}) as ${fieldName}`);
882
+ }
838
883
  } else {
839
884
  selectFields.push(`${mainAlias}.${fieldName}`);
840
885
  }
@@ -876,12 +921,13 @@ OFFSET ?`;
876
921
  }
877
922
  return { sql, params };
878
923
  }
879
- function buildTranslationQueryById(table, schema, id, lang, fallbackLang) {
924
+ function buildTranslationQueryById(table, schema, id, lang, fallbackLang, mainFallbackFields) {
880
925
  return buildTranslationQuery({
881
926
  table,
882
927
  schema,
883
928
  lang,
884
929
  fallbackLang,
930
+ mainFallbackFields,
885
931
  where: { id },
886
932
  limit: 1
887
933
  });
@@ -984,6 +1030,22 @@ function parseJSONSchema(json) {
984
1030
  tables
985
1031
  };
986
1032
  }
1033
+ function normalizeJsonType(type) {
1034
+ if (Array.isArray(type)) {
1035
+ const first = type[0];
1036
+ if (typeof first === "string") {
1037
+ const arrayType = `${first}[]`;
1038
+ return { type: arrayType };
1039
+ }
1040
+ if (typeof first === "object" && first !== null) {
1041
+ return { type: "object[]", properties: first };
1042
+ }
1043
+ }
1044
+ if (typeof type === "object" && type !== null && !Array.isArray(type)) {
1045
+ return { type: "object", properties: type };
1046
+ }
1047
+ return { type };
1048
+ }
987
1049
  function convertJSONField(jsonField) {
988
1050
  let ref;
989
1051
  if (jsonField.ref) {
@@ -998,13 +1060,15 @@ function convertJSONField(jsonField) {
998
1060
  };
999
1061
  }
1000
1062
  }
1063
+ const { type, properties } = normalizeJsonType(jsonField.type);
1001
1064
  return {
1002
- type: jsonField.type,
1065
+ type,
1003
1066
  nullable: jsonField.nullable ?? !jsonField.required,
1004
1067
  unique: jsonField.unique ?? false,
1005
1068
  primary: jsonField.primary ?? false,
1006
1069
  default: jsonField.default,
1007
1070
  translatable: jsonField.translatable ?? false,
1071
+ properties,
1008
1072
  ref
1009
1073
  };
1010
1074
  }
@@ -1015,7 +1079,7 @@ function deserializeValue(value, fieldType) {
1015
1079
  if (fieldType === "bool") {
1016
1080
  return value === 1 || value === true;
1017
1081
  }
1018
- if (fieldType === "json" && typeof value === "string") {
1082
+ if (isJsonType(fieldType) && typeof value === "string") {
1019
1083
  try {
1020
1084
  return JSON.parse(value);
1021
1085
  } catch {
@@ -1039,7 +1103,7 @@ function serializeValue(value, fieldType) {
1039
1103
  if (fieldType === "bool") {
1040
1104
  return value ? 1 : 0;
1041
1105
  }
1042
- if (fieldType === "json" && typeof value !== "string") {
1106
+ if (isJsonType(fieldType) && typeof value !== "string") {
1043
1107
  return JSON.stringify(value);
1044
1108
  }
1045
1109
  return value;
@@ -1073,6 +1137,7 @@ export {
1073
1137
  mergeSchemas,
1074
1138
  isValidSchema,
1075
1139
  isRequiredField,
1140
+ isJsonType,
1076
1141
  hasTranslatableFields,
1077
1142
  getTranslationTableFields,
1078
1143
  getTranslatableFields,
@@ -146,7 +146,43 @@ export declare const f: {
146
146
  */
147
147
  timestamp: () => FieldBuilder;
148
148
  /**
149
- * JSON field (TEXT with JSON serialization)
149
+ * JSON field (TEXT with JSON serialization) — untyped, generates Record<string, unknown>
150
150
  */
151
151
  json: () => FieldBuilder;
152
+ /**
153
+ * String array field — stored as JSON TEXT, generates string[]
154
+ */
155
+ stringArray: () => FieldBuilder;
156
+ /**
157
+ * Number array field — stored as JSON TEXT, generates number[]
158
+ */
159
+ numberArray: () => FieldBuilder;
160
+ /**
161
+ * Boolean array field — stored as JSON TEXT, generates boolean[]
162
+ */
163
+ boolArray: () => FieldBuilder;
164
+ /**
165
+ * Typed object field — stored as JSON TEXT, generates typed interface
166
+ * @param properties - Property definitions. Keys ending with "?" are optional.
167
+ * Values: "string" | "number" | "boolean"
168
+ *
169
+ * @example
170
+ * ```typescript
171
+ * f.object({ key: "string", value: "number" })
172
+ * // Generates: { key: string; value: number }
173
+ * ```
174
+ */
175
+ object: (properties: Record<string, string>) => FieldBuilder;
176
+ /**
177
+ * Typed object array field — stored as JSON TEXT, generates Array<{...}>
178
+ * @param properties - Property definitions. Keys ending with "?" are optional.
179
+ * Values: "string" | "number" | "boolean"
180
+ *
181
+ * @example
182
+ * ```typescript
183
+ * f.objectArray({ id: "number", slug: "string", "name?": "string" })
184
+ * // Generates: Array<{ id: number; slug: string; name?: string }>
185
+ * ```
186
+ */
187
+ objectArray: (properties: Record<string, string>) => FieldBuilder;
152
188
  };
package/dist/types.d.ts CHANGED
@@ -5,8 +5,16 @@
5
5
  */
6
6
  /**
7
7
  * Supported field types
8
+ *
9
+ * Primitive types map directly to SQL column types.
10
+ * Typed JSON types (string[], object[], etc.) store as TEXT in SQL
11
+ * but generate accurate TypeScript types.
12
+ */
13
+ export type FieldType = "id" | "string" | "text" | "int" | "decimal" | "bool" | "timestamp" | "json" | "string[]" | "number[]" | "boolean[]" | "object" | "object[]";
14
+ /**
15
+ * Check if a field type is JSON-like (stored as TEXT in SQL, serialized as JSON)
8
16
  */
9
- export type FieldType = "id" | "string" | "text" | "int" | "decimal" | "bool" | "timestamp" | "json";
17
+ export declare function isJsonType(type: FieldType): boolean;
10
18
  /**
11
19
  * Foreign key reference definition (MongoDB-style)
12
20
  */
@@ -26,6 +34,7 @@ export interface FieldDefinition {
26
34
  primary: boolean;
27
35
  default?: unknown;
28
36
  translatable: boolean;
37
+ properties?: Record<string, string>;
29
38
  ref?: string | FieldReference;
30
39
  required?: boolean;
31
40
  trim?: boolean;
@@ -74,11 +83,21 @@ export interface SchemaInput {
74
83
  languages: string[] | LanguageConfig;
75
84
  tables: Record<string, Record<string, FieldBuilderLike>>;
76
85
  }
86
+ /**
87
+ * JSON-native type syntax for schema.json
88
+ *
89
+ * Accepts:
90
+ * - string: primitive type ("string", "int", "json", etc.)
91
+ * - ["string"]: primitive array
92
+ * - [{ id: "number", name: "string" }]: typed object array
93
+ * - { key: "string", value: "number" }: typed object
94
+ */
95
+ export type JSONFieldType = FieldType | [string] | [Record<string, string>] | Record<string, string>;
77
96
  /**
78
97
  * JSON Schema Field Definition
79
98
  */
80
99
  export interface JSONFieldDefinition {
81
- type: FieldType;
100
+ type: JSONFieldType;
82
101
  translatable?: boolean;
83
102
  required?: boolean;
84
103
  unique?: boolean;
@@ -136,6 +155,8 @@ export interface WhereCondition {
136
155
  $notLike?: string;
137
156
  $between?: [unknown, unknown];
138
157
  $isNull?: boolean;
158
+ $contains?: unknown;
159
+ $containsAny?: unknown[];
139
160
  $not?: WhereCondition;
140
161
  $and?: WhereCondition[];
141
162
  $or?: WhereCondition[];
@@ -13,6 +13,11 @@ export interface TranslationQueryOptions {
13
13
  schema: SchemaDefinition;
14
14
  lang: string;
15
15
  fallbackLang?: string;
16
+ /**
17
+ * Main-table fields that can be safely used as cache fallback in COALESCE.
18
+ * If omitted, all translatable fields are assumed available in main table.
19
+ */
20
+ mainFallbackFields?: string[];
16
21
  where?: Record<string, unknown>;
17
22
  orderBy?: OrderByOption[];
18
23
  limit?: number;
@@ -32,7 +37,7 @@ export declare function buildTranslationQuery(options: TranslationQueryOptions):
32
37
  /**
33
38
  * Build query for single record by ID with translations
34
39
  */
35
- export declare function buildTranslationQueryById(table: string, schema: SchemaDefinition, id: number | string, lang: string, fallbackLang?: string): TranslationQueryResult;
40
+ export declare function buildTranslationQueryById(table: string, schema: SchemaDefinition, id: number | string, lang: string, fallbackLang?: string, mainFallbackFields?: string[]): TranslationQueryResult;
36
41
  /**
37
42
  * Build INSERT statement for translation
38
43
  */
@@ -12,6 +12,7 @@
12
12
  * - Null: $isNull
13
13
  * - Negation: $not (field-level)
14
14
  * - Logical: $and, $or, $nor
15
+ * - JSON Array: $contains, $containsAny (for JSON array fields stored as TEXT)
15
16
  */
16
17
  export interface WhereResult {
17
18
  sql: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@promakeai/orm",
3
- "version": "1.0.1",
3
+ "version": "1.0.4",
4
4
  "description": "Database-agnostic ORM core - works in browser and Node.js",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/ORM.ts CHANGED
@@ -100,7 +100,10 @@ export class ORM {
100
100
 
101
101
  // Resolve populate if schema is available
102
102
  if (populate && this.schema) {
103
- records = await this.resolvePopulateForRecords(records, table, populate);
103
+ records = await this.resolvePopulateForRecords(records, table, populate, {
104
+ lang: opts.lang,
105
+ fallbackLang: opts.fallbackLang,
106
+ });
104
107
  }
105
108
 
106
109
  return records;
@@ -132,7 +135,8 @@ export class ORM {
132
135
  const [populated] = await this.resolvePopulateForRecords(
133
136
  [record],
134
137
  table,
135
- populate
138
+ populate,
139
+ { lang: opts.lang, fallbackLang: opts.fallbackLang }
136
140
  );
137
141
  return populated;
138
142
  }
@@ -346,7 +350,8 @@ export class ORM {
346
350
  private async resolvePopulateForRecords<T>(
347
351
  records: T[],
348
352
  table: string,
349
- populate: QueryOptions["populate"]
353
+ populate: QueryOptions["populate"],
354
+ queryOptions?: { lang?: string; fallbackLang?: string }
350
355
  ): Promise<T[]> {
351
356
  if (!this.schema) return records;
352
357
 
@@ -356,13 +361,17 @@ export class ORM {
356
361
  console.warn("Populate validation warnings:", validation.errors);
357
362
  }
358
363
 
359
- // Create adapter wrapper for populate resolver
364
+ // Create adapter wrapper for populate resolver (passes lang for translations)
360
365
  const adapterWrapper = {
361
366
  findMany: <R = unknown>(
362
367
  t: string,
363
368
  opts?: { where?: Record<string, unknown> }
364
369
  ): Promise<R[]> => {
365
- return this.adapter.list<R>(t, opts);
370
+ return this.adapter.list<R>(t, {
371
+ ...opts,
372
+ lang: queryOptions?.lang,
373
+ fallbackLang: queryOptions?.fallbackLang,
374
+ });
366
375
  },
367
376
  };
368
377
 
package/src/index.ts CHANGED
@@ -116,6 +116,9 @@ export type { PopulateAdapter } from "./utils/populateResolver";
116
116
  // JSON Schema Converter
117
117
  export { parseJSONSchema } from "./utils/jsonConverter";
118
118
 
119
+ // Type Helpers
120
+ export { isJsonType } from "./types";
121
+
119
122
  // Row Serializer/Deserializer
120
123
  export { deserializeRow, serializeRow } from "./utils/deserializer";
121
124
 
@@ -134,6 +137,7 @@ export type {
134
137
  SchemaInput,
135
138
 
136
139
  // JSON Schema types (AI-friendly)
140
+ JSONFieldType,
137
141
  JSONFieldDefinition,
138
142
  JSONTableDefinition,
139
143
  JSONSchemaDefinition,
@@ -237,8 +237,57 @@ export const f = {
237
237
  timestamp: (): FieldBuilder => new FieldBuilder("timestamp"),
238
238
 
239
239
  /**
240
- * JSON field (TEXT with JSON serialization)
240
+ * JSON field (TEXT with JSON serialization) — untyped, generates Record<string, unknown>
241
241
  */
242
242
  json: (): FieldBuilder => new FieldBuilder("json"),
243
+
244
+ /**
245
+ * String array field — stored as JSON TEXT, generates string[]
246
+ */
247
+ stringArray: (): FieldBuilder => new FieldBuilder("string[]"),
248
+
249
+ /**
250
+ * Number array field — stored as JSON TEXT, generates number[]
251
+ */
252
+ numberArray: (): FieldBuilder => new FieldBuilder("number[]"),
253
+
254
+ /**
255
+ * Boolean array field — stored as JSON TEXT, generates boolean[]
256
+ */
257
+ boolArray: (): FieldBuilder => new FieldBuilder("boolean[]"),
258
+
259
+ /**
260
+ * Typed object field — stored as JSON TEXT, generates typed interface
261
+ * @param properties - Property definitions. Keys ending with "?" are optional.
262
+ * Values: "string" | "number" | "boolean"
263
+ *
264
+ * @example
265
+ * ```typescript
266
+ * f.object({ key: "string", value: "number" })
267
+ * // Generates: { key: string; value: number }
268
+ * ```
269
+ */
270
+ object: (properties: Record<string, string>): FieldBuilder => {
271
+ const builder = new FieldBuilder("object");
272
+ builder["definition"].properties = properties;
273
+ return builder;
274
+ },
275
+
276
+ /**
277
+ * Typed object array field — stored as JSON TEXT, generates Array<{...}>
278
+ * @param properties - Property definitions. Keys ending with "?" are optional.
279
+ * Values: "string" | "number" | "boolean"
280
+ *
281
+ * @example
282
+ * ```typescript
283
+ * f.objectArray({ id: "number", slug: "string", "name?": "string" })
284
+ * // Generates: Array<{ id: number; slug: string; name?: string }>
285
+ * ```
286
+ */
287
+ objectArray: (properties: Record<string, string>): FieldBuilder => {
288
+ const builder = new FieldBuilder("object[]");
289
+ builder["definition"].properties = properties;
290
+ return builder;
291
+ },
243
292
  };
244
293
 
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import type { TableDefinition, FieldDefinition, FieldReference } from "../types";
8
+ import { isJsonType } from "../types";
8
9
 
9
10
  /**
10
11
  * Get names of translatable fields in a table
@@ -55,7 +56,7 @@ export function getReferenceFields(
55
56
  table: TableDefinition
56
57
  ): [string, FieldReference][] {
57
58
  return Object.entries(table.fields)
58
- .filter(([_, field]) => field.ref)
59
+ .filter(([_, field]) => field.ref && !isJsonType(field.type))
59
60
  .map(([name, field]) => {
60
61
  const ref = field.ref!;
61
62
  // Normalize string to FieldReference
package/src/types.ts CHANGED
@@ -8,6 +8,10 @@
8
8
 
9
9
  /**
10
10
  * Supported field types
11
+ *
12
+ * Primitive types map directly to SQL column types.
13
+ * Typed JSON types (string[], object[], etc.) store as TEXT in SQL
14
+ * but generate accurate TypeScript types.
11
15
  */
12
16
  export type FieldType =
13
17
  | "id"
@@ -17,7 +21,19 @@ export type FieldType =
17
21
  | "decimal"
18
22
  | "bool"
19
23
  | "timestamp"
20
- | "json";
24
+ | "json"
25
+ | "string[]"
26
+ | "number[]"
27
+ | "boolean[]"
28
+ | "object"
29
+ | "object[]";
30
+
31
+ /**
32
+ * Check if a field type is JSON-like (stored as TEXT in SQL, serialized as JSON)
33
+ */
34
+ export function isJsonType(type: FieldType): boolean {
35
+ return type === "json" || type === "string[]" || type === "number[]" || type === "boolean[]" || type === "object" || type === "object[]";
36
+ }
21
37
 
22
38
  /**
23
39
  * Foreign key reference definition (MongoDB-style)
@@ -40,6 +56,10 @@ export interface FieldDefinition {
40
56
  default?: unknown;
41
57
  translatable: boolean;
42
58
 
59
+ // Typed JSON properties (for "object" and "object[]" types)
60
+ // Keys ending with "?" are optional. Values: "string" | "number" | "boolean"
61
+ properties?: Record<string, string>;
62
+
43
63
  // Reference (MongoDB-style)
44
64
  ref?: string | FieldReference;
45
65
 
@@ -101,11 +121,26 @@ export interface SchemaInput {
101
121
 
102
122
  // ==================== JSON Schema Types (AI Agent Friendly) ====================
103
123
 
124
+ /**
125
+ * JSON-native type syntax for schema.json
126
+ *
127
+ * Accepts:
128
+ * - string: primitive type ("string", "int", "json", etc.)
129
+ * - ["string"]: primitive array
130
+ * - [{ id: "number", name: "string" }]: typed object array
131
+ * - { key: "string", value: "number" }: typed object
132
+ */
133
+ export type JSONFieldType =
134
+ | FieldType
135
+ | [string]
136
+ | [Record<string, string>]
137
+ | Record<string, string>;
138
+
104
139
  /**
105
140
  * JSON Schema Field Definition
106
141
  */
107
142
  export interface JSONFieldDefinition {
108
- type: FieldType;
143
+ type: JSONFieldType;
109
144
  translatable?: boolean;
110
145
  required?: boolean;
111
146
  unique?: boolean;
@@ -176,6 +211,8 @@ export interface WhereCondition {
176
211
  $notLike?: string;
177
212
  $between?: [unknown, unknown];
178
213
  $isNull?: boolean;
214
+ $contains?: unknown;
215
+ $containsAny?: unknown[];
179
216
  $not?: WhereCondition;
180
217
  $and?: WhereCondition[];
181
218
  $or?: WhereCondition[];
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import type { FieldType, FieldDefinition } from "../types";
10
+ import { isJsonType } from "../types";
10
11
 
11
12
  /**
12
13
  * Deserialize a single value from DB storage type to application type
@@ -18,7 +19,7 @@ function deserializeValue(value: unknown, fieldType: FieldType): unknown {
18
19
  return value === 1 || value === true;
19
20
  }
20
21
 
21
- if (fieldType === "json" && typeof value === "string") {
22
+ if (isJsonType(fieldType) && typeof value === "string") {
22
23
  try {
23
24
  return JSON.parse(value);
24
25
  } catch {
@@ -61,7 +62,7 @@ function serializeValue(value: unknown, fieldType: FieldType): unknown {
61
62
  return value ? 1 : 0;
62
63
  }
63
64
 
64
- if (fieldType === "json" && typeof value !== "string") {
65
+ if (isJsonType(fieldType) && typeof value !== "string") {
65
66
  return JSON.stringify(value);
66
67
  }
67
68
 
@@ -8,8 +8,10 @@
8
8
  import type {
9
9
  JSONSchemaDefinition,
10
10
  JSONFieldDefinition,
11
+ JSONFieldType,
11
12
  SchemaDefinition,
12
13
  FieldDefinition,
14
+ FieldType,
13
15
  TableDefinition,
14
16
  LanguageConfig,
15
17
  } from "../types";
@@ -63,6 +65,40 @@ export function parseJSONSchema(json: JSONSchemaDefinition): SchemaDefinition {
63
65
  };
64
66
  }
65
67
 
68
+ /**
69
+ * Normalize JSON-native type syntax to internal FieldType + properties
70
+ *
71
+ * - ["string"] → type: "string[]"
72
+ * - ["number"] → type: "number[]"
73
+ * - ["boolean"] → type: "boolean[]"
74
+ * - [{ id: "number", ... }] → type: "object[]", properties: { id: "number", ... }
75
+ * - { key: "string", ... } → type: "object", properties: { key: "string", ... }
76
+ * - "string" / "json" / ... → pass through as-is
77
+ */
78
+ function normalizeJsonType(type: JSONFieldType): { type: FieldType; properties?: Record<string, string> } {
79
+ // Array syntax: ["string"] or [{ ... }]
80
+ if (Array.isArray(type)) {
81
+ const first = type[0];
82
+ if (typeof first === "string") {
83
+ // Primitive array: ["string"] → "string[]"
84
+ const arrayType = `${first}[]` as FieldType;
85
+ return { type: arrayType };
86
+ }
87
+ if (typeof first === "object" && first !== null) {
88
+ // Object array: [{ id: "number", ... }] → "object[]" + properties
89
+ return { type: "object[]", properties: first as Record<string, string> };
90
+ }
91
+ }
92
+
93
+ // Object syntax: { key: "string", ... } → "object" + properties
94
+ if (typeof type === "object" && type !== null && !Array.isArray(type)) {
95
+ return { type: "object", properties: type as Record<string, string> };
96
+ }
97
+
98
+ // String: pass through as-is
99
+ return { type: type as FieldType };
100
+ }
101
+
66
102
  /**
67
103
  * Convert single JSON field to internal FieldDefinition
68
104
  */
@@ -82,13 +118,16 @@ function convertJSONField(jsonField: JSONFieldDefinition): FieldDefinition {
82
118
  }
83
119
  }
84
120
 
121
+ const { type, properties } = normalizeJsonType(jsonField.type);
122
+
85
123
  return {
86
- type: jsonField.type,
124
+ type,
87
125
  nullable: jsonField.nullable ?? !jsonField.required,
88
126
  unique: jsonField.unique ?? false,
89
127
  primary: jsonField.primary ?? false,
90
128
  default: jsonField.default,
91
129
  translatable: jsonField.translatable ?? false,
130
+ properties,
92
131
  ref,
93
132
  };
94
133
  }
@@ -31,6 +31,7 @@ import type {
31
31
  PopulateOption,
32
32
  PopulateNested,
33
33
  } from "../types";
34
+ import { isJsonType } from "../types";
34
35
 
35
36
  /**
36
37
  * Adapter interface for fetching records
@@ -79,7 +80,7 @@ function getRefInfo(
79
80
  field: isString ? "id" : ref.field || "id",
80
81
  localField: fieldName,
81
82
  targetField: isString ? "id" : ref.field || "id",
82
- isArray: Array.isArray(field.default) || fieldName.endsWith("Ids"),
83
+ isArray: isJsonType(field.type) || Array.isArray(field.default) || fieldName.endsWith("Ids"),
83
84
  };
84
85
  }
85
86
 
@@ -44,16 +44,21 @@ function buildAliasedWhereClause(
44
44
  /**
45
45
  * Options for translation queries
46
46
  */
47
- export interface TranslationQueryOptions {
48
- table: string;
49
- schema: SchemaDefinition;
50
- lang: string;
51
- fallbackLang?: string;
52
- where?: Record<string, unknown>;
53
- orderBy?: OrderByOption[];
54
- limit?: number;
55
- offset?: number;
56
- }
47
+ export interface TranslationQueryOptions {
48
+ table: string;
49
+ schema: SchemaDefinition;
50
+ lang: string;
51
+ fallbackLang?: string;
52
+ /**
53
+ * Main-table fields that can be safely used as cache fallback in COALESCE.
54
+ * If omitted, all translatable fields are assumed available in main table.
55
+ */
56
+ mainFallbackFields?: string[];
57
+ where?: Record<string, unknown>;
58
+ orderBy?: OrderByOption[];
59
+ limit?: number;
60
+ offset?: number;
61
+ }
57
62
 
58
63
  /**
59
64
  * Query result with SQL and parameters
@@ -73,12 +78,13 @@ export function buildTranslationQuery(
73
78
  table,
74
79
  schema,
75
80
  lang,
76
- fallbackLang = schema.languages.default,
77
- where,
78
- orderBy,
79
- limit,
80
- offset,
81
- } = options;
81
+ fallbackLang = schema.languages.default,
82
+ mainFallbackFields,
83
+ where,
84
+ orderBy,
85
+ limit,
86
+ offset,
87
+ } = options;
82
88
 
83
89
  const tableSchema = schema.tables[table];
84
90
  if (!tableSchema) {
@@ -94,22 +100,32 @@ export function buildTranslationQuery(
94
100
  const transTable = toTranslationTableName(table);
95
101
  const fkName = toTranslationFKName(table);
96
102
 
97
- const mainAlias = "m";
98
- const transAlias = "t";
99
- const fallbackAlias = "fb";
103
+ const mainAlias = "m";
104
+ const transAlias = "t";
105
+ const fallbackAlias = "fb";
106
+ const mainFallbackSet =
107
+ mainFallbackFields !== undefined
108
+ ? new Set(mainFallbackFields)
109
+ : null;
100
110
 
101
111
  // Build SELECT fields
102
112
  const selectFields: string[] = [];
103
113
 
104
- for (const [fieldName, field] of Object.entries(tableSchema.fields)) {
105
- if (field.translatable) {
106
- selectFields.push(
107
- `COALESCE(${transAlias}.${fieldName}, ${fallbackAlias}.${fieldName}) as ${fieldName}`
108
- );
109
- } else {
110
- selectFields.push(`${mainAlias}.${fieldName}`);
111
- }
112
- }
114
+ for (const [fieldName, field] of Object.entries(tableSchema.fields)) {
115
+ if (field.translatable) {
116
+ if (mainFallbackSet && !mainFallbackSet.has(fieldName)) {
117
+ selectFields.push(
118
+ `COALESCE(${transAlias}.${fieldName}, ${fallbackAlias}.${fieldName}) as ${fieldName}`
119
+ );
120
+ } else {
121
+ selectFields.push(
122
+ `COALESCE(${transAlias}.${fieldName}, ${fallbackAlias}.${fieldName}, ${mainAlias}.${fieldName}) as ${fieldName}`
123
+ );
124
+ }
125
+ } else {
126
+ selectFields.push(`${mainAlias}.${fieldName}`);
127
+ }
128
+ }
113
129
 
114
130
  let sql = `SELECT ${selectFields.join(", ")}
115
131
  FROM ${table} ${mainAlias}
@@ -154,22 +170,24 @@ LEFT JOIN ${transTable} ${fallbackAlias}
154
170
  /**
155
171
  * Build query for single record by ID with translations
156
172
  */
157
- export function buildTranslationQueryById(
158
- table: string,
159
- schema: SchemaDefinition,
160
- id: number | string,
161
- lang: string,
162
- fallbackLang?: string
163
- ): TranslationQueryResult {
164
- return buildTranslationQuery({
165
- table,
166
- schema,
167
- lang,
168
- fallbackLang,
169
- where: { id },
170
- limit: 1,
171
- });
172
- }
173
+ export function buildTranslationQueryById(
174
+ table: string,
175
+ schema: SchemaDefinition,
176
+ id: number | string,
177
+ lang: string,
178
+ fallbackLang?: string,
179
+ mainFallbackFields?: string[]
180
+ ): TranslationQueryResult {
181
+ return buildTranslationQuery({
182
+ table,
183
+ schema,
184
+ lang,
185
+ fallbackLang,
186
+ mainFallbackFields,
187
+ where: { id },
188
+ limit: 1,
189
+ });
190
+ }
173
191
 
174
192
  /**
175
193
  * Build simple query without translations
@@ -12,6 +12,7 @@
12
12
  * - Null: $isNull
13
13
  * - Negation: $not (field-level)
14
14
  * - Logical: $and, $or, $nor
15
+ * - JSON Array: $contains, $containsAny (for JSON array fields stored as TEXT)
15
16
  */
16
17
 
17
18
  export interface WhereResult {
@@ -68,6 +69,22 @@ function processFieldOperators(
68
69
  params.push(value[0], value[1]);
69
70
  } else if (op === "$isNull") {
70
71
  parts.push(value ? `${field} IS NULL` : `${field} IS NOT NULL`);
72
+ } else if (op === "$contains") {
73
+ // JSON array contains a single value: EXISTS (SELECT 1 FROM json_each(field) WHERE value = ?)
74
+ parts.push(`EXISTS (SELECT 1 FROM json_each(${field}) WHERE value = ?)`);
75
+ params.push(value);
76
+ } else if (op === "$containsAny") {
77
+ // JSON array contains any of the values: EXISTS (SELECT 1 FROM json_each(field) WHERE value IN (?, ?))
78
+ if (!Array.isArray(value)) {
79
+ throw new Error("$containsAny operator requires an array value");
80
+ }
81
+ if (value.length === 0) {
82
+ parts.push("0 = 1");
83
+ } else {
84
+ const placeholders = value.map(() => "?").join(", ");
85
+ parts.push(`EXISTS (SELECT 1 FROM json_each(${field}) WHERE value IN (${placeholders}))`);
86
+ params.push(...value);
87
+ }
71
88
  } else if (op === "$not") {
72
89
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
73
90
  throw new Error("$not operator requires an object with operators");