@carthooks/arcubase-cli 0.1.24 → 0.1.26

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.
@@ -16,16 +16,29 @@ type ArcubaseField = {
16
16
  type: string
17
17
  required: boolean
18
18
  options?: Record<string, unknown>
19
+ children?: ArcubaseField[]
19
20
  }
20
21
 
21
22
  type ArcubaseEntity = {
22
23
  appId: number
23
24
  id: number
24
25
  name: string
25
- sdkName: string
26
26
  fields: ArcubaseField[]
27
27
  }
28
28
 
29
+ type ArcubaseIngress = {
30
+ appId: number
31
+ id?: number
32
+ hashId: string
33
+ key: string
34
+ name: string
35
+ actions: string[]
36
+ entity: ArcubaseEntity
37
+ listOptions?: Record<string, unknown>
38
+ }
39
+
40
+ const identifierPattern = /^[A-Za-z][A-Za-z0-9_]*$/
41
+
29
42
  function isRecord(value: unknown): value is Record<string, any> {
30
43
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
31
44
  }
@@ -39,20 +52,10 @@ function toPascalCase(value: string): string {
39
52
  .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
40
53
  .split(/[^A-Za-z0-9]+/)
41
54
  .filter(Boolean)
42
- if (words.length === 0) return 'Entity'
55
+ if (words.length === 0) return 'Ingress'
43
56
  return words.map((word) => `${word[0].toUpperCase()}${word.slice(1)}`).join('')
44
57
  }
45
58
 
46
- function toCamelCase(value: string): string {
47
- const pascal = toPascalCase(value)
48
- return `${pascal[0].toLowerCase()}${pascal.slice(1)}`
49
- }
50
-
51
- function sanitizeEntityKey(entity: ArcubaseEntity): string {
52
- const fallback = entity.sdkName || `entity_${entity.id}`
53
- return toCamelCase(fallback) === 'entity' ? `entity${entity.id}` : toCamelCase(fallback)
54
- }
55
-
56
59
  function normalizeFieldType(type: string): string {
57
60
  switch (type) {
58
61
  case 'number':
@@ -80,82 +83,151 @@ function normalizeFieldType(type: string): string {
80
83
  }
81
84
  }
82
85
 
83
- function normalizeEntities(payload: unknown): ArcubaseEntity[] {
86
+ function normalizeField(field: Record<string, unknown>): ArcubaseField | undefined {
87
+ if (typeof field.id !== 'number' || typeof field.label !== 'string' || typeof field.type !== 'string') {
88
+ return undefined
89
+ }
90
+ const children = Array.isArray(field.children)
91
+ ? field.children
92
+ .filter((child): child is Record<string, unknown> => isRecord(child))
93
+ .map(normalizeField)
94
+ .filter((child): child is ArcubaseField => Boolean(child))
95
+ : undefined
96
+ return {
97
+ id: field.id,
98
+ label: field.label,
99
+ key: typeof field.key === 'string' ? field.key.trim() : '',
100
+ type: field.type,
101
+ required: field.required === true,
102
+ options: isRecord(field.options) ? field.options : undefined,
103
+ children,
104
+ }
105
+ }
106
+
107
+ function normalizeEntity(payload: unknown, appId: number): ArcubaseEntity {
108
+ if (!isRecord(payload) || typeof payload.id !== 'number' || typeof payload.name !== 'string') {
109
+ throw new CLIError('SDK_GEN_INVALID_SCHEMA', 'sdk-gen expected ingress.entity with id and name', 2)
110
+ }
111
+ const fields = Array.isArray(payload.fields)
112
+ ? payload.fields
113
+ .filter((field): field is Record<string, unknown> => isRecord(field))
114
+ .map(normalizeField)
115
+ .filter((field): field is ArcubaseField => Boolean(field))
116
+ : []
117
+ return {
118
+ appId,
119
+ id: payload.id,
120
+ name: payload.name,
121
+ fields,
122
+ }
123
+ }
124
+
125
+ function normalizeActions(ingress: Record<string, any>): string[] {
126
+ const policy = isRecord(ingress.policy)
127
+ ? ingress.policy
128
+ : isRecord(ingress.options) && isRecord(ingress.options.policy)
129
+ ? ingress.options.policy
130
+ : undefined
131
+ const source = Array.isArray(ingress.actions) ? ingress.actions : Array.isArray(policy?.actions) ? policy.actions : []
132
+ return source.filter((action): action is string => typeof action === 'string' && action.trim() !== '').map((action) => action.trim())
133
+ }
134
+
135
+ function normalizeIngresses(payload: unknown): ArcubaseIngress[] {
84
136
  const root = unwrapData(payload)
85
137
  if (!isRecord(root)) {
86
- throw new CLIError('SDK_GEN_INVALID_SCHEMA', 'sdk-gen expected an app detail object', 2)
138
+ throw new CLIError('SDK_GEN_INVALID_SCHEMA', 'sdk-gen expected an app ingress schema object', 2)
87
139
  }
88
140
  const appId = typeof root.id === 'number' ? root.id : typeof root.app_id === 'number' ? root.app_id : undefined
89
141
  if (!appId) {
90
- throw new CLIError('SDK_GEN_APP_ID_REQUIRED', 'sdk-gen expected app id in app detail response', 2)
142
+ throw new CLIError('SDK_GEN_APP_ID_REQUIRED', 'sdk-gen expected app id in schema payload', 2)
91
143
  }
92
- const sourceEntities = Array.isArray(root.entities)
93
- ? root.entities
94
- : Array.isArray(root.tables)
95
- ? root.tables
96
- : Array.isArray(root.entitys)
97
- ? root.entitys
98
- : []
99
- const entities = sourceEntities
100
- .filter((item): item is Record<string, unknown> => isRecord(item))
101
- .filter((item) => typeof item.id === 'number' && typeof item.name === 'string')
144
+ const sourceIngresses = Array.isArray(root.ingresses) ? root.ingresses : []
145
+ if (sourceIngresses.length === 0) {
146
+ throw new CLIError('SDK_GEN_NO_INGRESSES', 'sdk-gen could not find ingresses in schema payload', 2)
147
+ }
148
+ const ingresses = sourceIngresses
149
+ .filter((item): item is Record<string, any> => isRecord(item))
102
150
  .map((item) => {
103
- const fields = Array.isArray(item.fields)
104
- ? item.fields
105
- .filter((field): field is Record<string, unknown> => isRecord(field))
106
- .filter((field) => typeof field.id === 'number' && typeof field.label === 'string' && typeof field.type === 'string')
107
- .map((field) => ({
108
- id: field.id as number,
109
- label: field.label as string,
110
- key: typeof field.key === 'string' ? field.key.trim() : '',
111
- type: field.type as string,
112
- required: field.required === true,
113
- options: isRecord(field.options) ? field.options : undefined,
114
- }))
115
- : []
116
- const entity: ArcubaseEntity = {
151
+ const entity = normalizeEntity(item.entity, appId)
152
+ const options = isRecord(item.options) ? item.options : {}
153
+ const hashId = typeof item.hash_id === 'string'
154
+ ? item.hash_id.trim()
155
+ : typeof item.hashId === 'string'
156
+ ? item.hashId.trim()
157
+ : ''
158
+ return {
117
159
  appId,
118
- id: item.id as number,
119
- name: item.name as string,
120
- sdkName: typeof item.key === 'string' && item.key.trim() ? item.key.trim() : '',
121
- fields,
160
+ id: typeof item.id === 'number' ? item.id : undefined,
161
+ hashId,
162
+ key: typeof item.key === 'string' ? item.key.trim() : '',
163
+ name: typeof item.name === 'string' && item.name.trim() ? item.name.trim() : String(item.key ?? ''),
164
+ actions: normalizeActions(item),
165
+ entity,
166
+ listOptions: isRecord(options.list_options) ? options.list_options : isRecord(item.list_options) ? item.list_options : undefined,
122
167
  }
123
- entity.sdkName = sanitizeEntityKey(entity)
124
- return entity
125
168
  })
126
- if (entities.length === 0) {
127
- throw new CLIError('SDK_GEN_NO_ENTITIES', 'sdk-gen could not find entities in app detail response', 2)
169
+ validateIngresses(ingresses)
170
+ return ingresses
171
+ }
172
+
173
+ function walkFields(fields: ArcubaseField[], visit: (field: ArcubaseField) => void) {
174
+ for (const field of fields) {
175
+ visit(field)
176
+ if (field.children && field.children.length > 0) {
177
+ walkFields(field.children, visit)
178
+ }
128
179
  }
129
- validateEntities(entities)
130
- return entities
131
180
  }
132
181
 
133
- function validateEntities(entities: ArcubaseEntity[]) {
134
- const entityKeys = new Set<string>()
135
- for (const entity of entities) {
136
- if (entityKeys.has(entity.sdkName)) {
137
- throw new CLIError('SDK_GEN_DUPLICATE_ENTITY_KEY', `duplicate generated entity key: ${entity.sdkName}`, 2)
182
+ function allFields(entity: ArcubaseEntity): ArcubaseField[] {
183
+ const out: ArcubaseField[] = []
184
+ walkFields(entity.fields, (field) => out.push(field))
185
+ return out
186
+ }
187
+
188
+ function validateIngresses(ingresses: ArcubaseIngress[]) {
189
+ const ingressKeys = new Set<string>()
190
+ for (const ingress of ingresses) {
191
+ if (!ingress.key) {
192
+ throw new CLIError('SDK_GEN_INGRESS_KEY_REQUIRED', `ingress.key is required for ${ingress.name || 'unnamed ingress'}`, 2)
193
+ }
194
+ if (!identifierPattern.test(ingress.key)) {
195
+ throw new CLIError('SDK_GEN_INVALID_INGRESS_KEY', `ingress.key must be a TypeScript identifier: ${ingress.key}`, 2)
196
+ }
197
+ if (ingressKeys.has(ingress.key)) {
198
+ throw new CLIError('SDK_GEN_DUPLICATE_INGRESS_KEY', `duplicate ingress.key: ${ingress.key}`, 2)
199
+ }
200
+ ingressKeys.add(ingress.key)
201
+ if (!ingress.hashId) {
202
+ throw new CLIError('SDK_GEN_INGRESS_HASH_ID_REQUIRED', `ingress.hash_id is required for ${ingress.key}`, 2)
138
203
  }
139
- entityKeys.add(entity.sdkName)
140
204
 
141
205
  const fieldKeys = new Set<string>()
142
- for (const field of entity.fields) {
206
+ for (const field of allFields(ingress.entity)) {
143
207
  if (!field.key) {
144
- throw new CLIError('SDK_GEN_FIELD_KEY_REQUIRED', `field.key is required for ${entity.name}.${field.label} (${field.id})`, 2)
208
+ throw new CLIError('SDK_GEN_FIELD_KEY_REQUIRED', `field.key is required for ${ingress.entity.name}.${field.label} (${field.id})`, 2)
145
209
  }
146
- if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(field.key)) {
147
- throw new CLIError('SDK_GEN_INVALID_FIELD_KEY', `field.key must be a TypeScript identifier: ${entity.name}.${field.key}`, 2)
210
+ if (!identifierPattern.test(field.key)) {
211
+ throw new CLIError('SDK_GEN_INVALID_FIELD_KEY', `field.key must be a TypeScript identifier: ${ingress.entity.name}.${field.key}`, 2)
148
212
  }
149
213
  if (fieldKeys.has(field.key)) {
150
- throw new CLIError('SDK_GEN_DUPLICATE_FIELD_KEY', `duplicate field.key in ${entity.name}: ${field.key}`, 2)
214
+ throw new CLIError('SDK_GEN_DUPLICATE_FIELD_KEY', `duplicate field.key in ${ingress.entity.name}: ${field.key}`, 2)
151
215
  }
152
216
  fieldKeys.add(field.key)
153
217
  }
154
218
  }
155
219
  }
156
220
 
221
+ function hasAction(ingress: ArcubaseIngress, action: string): boolean {
222
+ return ingress.actions.includes(action)
223
+ }
224
+
225
+ function methodLines(lines: string[]): string {
226
+ return lines.length > 0 ? `${lines.join('\n\n')}\n` : ''
227
+ }
228
+
157
229
  function emitRuntime(): string {
158
- return `export type ArcubaseSdkConfig = {
230
+ return `export type ArcubaseClientConfig = {
159
231
  baseURL: string
160
232
  accessToken?: string
161
233
  refreshToken?: string
@@ -169,7 +241,6 @@ export type ArcubaseRow<TFields> = {
169
241
  }
170
242
 
171
243
  export type ArcubaseRuntime = {
172
- config: ArcubaseSdkConfig
173
244
  request<T>(method: string, endpoint: string, body?: unknown): Promise<T>
174
245
  }
175
246
 
@@ -178,7 +249,7 @@ type ArcubaseEnvelope<T> = {
178
249
  error?: { message?: string; key?: string; type?: string }
179
250
  }
180
251
 
181
- export function createRuntime(config: ArcubaseSdkConfig): ArcubaseRuntime {
252
+ export function createRuntime(config: ArcubaseClientConfig): ArcubaseRuntime {
182
253
  let accessToken = config.accessToken ?? ''
183
254
  let refreshToken = config.refreshToken ?? ''
184
255
 
@@ -189,6 +260,7 @@ export function createRuntime(config: ArcubaseSdkConfig): ArcubaseRuntime {
189
260
  headers: {
190
261
  'Content-Type': 'application/json',
191
262
  ...(accessToken ? { Authorization: \`Bearer \${accessToken}\` } : {}),
263
+ ...(refreshToken ? { 'X-Arcubase-Refresh-Token': refreshToken } : {}),
192
264
  },
193
265
  body: body === undefined ? undefined : JSON.stringify(body),
194
266
  })
@@ -199,22 +271,73 @@ export function createRuntime(config: ArcubaseSdkConfig): ArcubaseRuntime {
199
271
  return payload?.data as T
200
272
  }
201
273
 
202
- return {
203
- config: { ...config, accessToken, refreshToken },
204
- request,
274
+ return { request }
275
+ }
276
+
277
+ export function toArcubaseFields<T extends Record<string, any>>(fields: Partial<T>, fieldIds: Record<string, number>): Record<string, any> {
278
+ const out: Record<string, any> = {}
279
+ for (const [key, value] of Object.entries(fields)) {
280
+ if (value === undefined) continue
281
+ const fieldId = fieldIds[key]
282
+ if (!fieldId) {
283
+ throw new Error(\`Unknown Arcubase field: \${key}\`)
284
+ }
285
+ out[String(fieldId)] = value
286
+ }
287
+ return out
288
+ }
289
+
290
+ export function fromArcubaseFields<T>(raw: Record<string, any> | undefined, fieldIds: Record<string, number>): T {
291
+ const out: Record<string, any> = {}
292
+ const source = raw ?? {}
293
+ for (const [key, fieldId] of Object.entries(fieldIds)) {
294
+ if (String(fieldId) in source) {
295
+ out[key] = source[String(fieldId)]
296
+ }
205
297
  }
298
+ return out as T
299
+ }
300
+
301
+ export function toArcubaseColumn(column: string, fieldIds: Record<string, number>): string {
302
+ return fieldIds[column] ? \`:\${fieldIds[column]}\` : column
303
+ }
304
+
305
+ export function mapArcubaseCondition(value: unknown, fieldIds: Record<string, number>): unknown {
306
+ if (Array.isArray(value)) {
307
+ return value.map((item) => mapArcubaseCondition(item, fieldIds))
308
+ }
309
+ if (!value || typeof value !== 'object') {
310
+ return value
311
+ }
312
+ const out: Record<string, unknown> = {}
313
+ for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
314
+ if (key === 'column' && typeof child === 'string') {
315
+ out[key] = toArcubaseColumn(child, fieldIds)
316
+ } else {
317
+ const mappedKey = fieldIds[key] ? \`:\${fieldIds[key]}\` : key
318
+ out[mappedKey] = mapArcubaseCondition(child, fieldIds)
319
+ }
320
+ }
321
+ return out
322
+ }
323
+
324
+ export function mapArcubaseSorts<T extends { column: string; order?: string }>(sorts: T[] | undefined, fieldIds: Record<string, number>): T[] | undefined {
325
+ return sorts?.map((sort) => ({ ...sort, column: toArcubaseColumn(sort.column, fieldIds) }))
206
326
  }
207
327
  `
208
328
  }
209
329
 
210
- function emitSchema(entities: ArcubaseEntity[]): string {
330
+ function emitSchema(ingresses: ArcubaseIngress[]): string {
211
331
  const schema = {
212
- entities: Object.fromEntries(entities.map((entity) => [
213
- entity.sdkName,
332
+ ingresses: Object.fromEntries(ingresses.map((ingress) => [
333
+ ingress.key,
214
334
  {
215
- entityId: entity.id,
216
- name: entity.name,
217
- fields: Object.fromEntries(entity.fields.map((field) => [
335
+ appId: ingress.appId,
336
+ ingressId: ingress.hashId,
337
+ entityId: ingress.entity.id,
338
+ name: ingress.name,
339
+ actions: ingress.actions,
340
+ fields: Object.fromEntries(allFields(ingress.entity).map((field) => [
218
341
  field.key,
219
342
  {
220
343
  fieldId: field.id,
@@ -228,19 +351,189 @@ function emitSchema(entities: ArcubaseEntity[]): string {
228
351
  }
229
352
  return `export const arcubaseSchema = ${JSON.stringify(schema, null, 2)} as const
230
353
 
231
- export type ArcubaseEntityKey = keyof typeof arcubaseSchema.entities
354
+ export type ArcubaseIngressKey = keyof typeof arcubaseSchema.ingresses
232
355
  `
233
356
  }
234
357
 
235
- function emitEntity(entity: ArcubaseEntity): string {
236
- const typeName = toPascalCase(entity.sdkName)
237
- const fieldLines = entity.fields.map((field) => {
358
+ function emitIngress(ingress: ArcubaseIngress): string {
359
+ const typeName = toPascalCase(ingress.key)
360
+ const fields = allFields(ingress.entity)
361
+ const fieldLines = fields.map((field) => {
238
362
  const optional = field.required ? '' : '?'
239
363
  return ` ${field.key}${optional}: ${normalizeFieldType(field.type)}`
240
364
  })
241
- const fieldMapLines = entity.fields.map((field) => ` ${field.key}: ${field.id},`)
365
+ const fieldMapLines = fields.map((field) => ` ${field.key}: ${field.id},`)
366
+ const methodChunks: string[] = []
367
+
368
+ if (hasAction(ingress, 'view')) {
369
+ methodChunks.push(` async query(params: ${typeName}QueryParams = {}): Promise<{ items: ArcubaseRow<${typeName}Fields>[]; total?: number; offset?: number; limit?: number }> {
370
+ const data = await runtime.request<{ items?: Record<string, any>[]; total?: number; offset?: number; limit?: number }>('POST', \`/entity/${ingress.appId}/${ingress.entity.id}/data-query\`, {
371
+ ...params,
372
+ policy_id: ingressId,
373
+ search: mapArcubaseCondition(params.search, fieldIds),
374
+ sorts: mapArcubaseSorts(params.sorts, fieldIds),
375
+ })
376
+ return {
377
+ items: (data.items ?? []).map(mapRow),
378
+ total: data.total,
379
+ offset: data.offset,
380
+ limit: data.limit,
381
+ }
382
+ },
242
383
 
243
- return `import type { ArcubaseRuntime, ArcubaseRow } from '../runtime.js'
384
+ async get(rowId: string | number): Promise<ArcubaseRow<${typeName}Fields>> {
385
+ const data = await runtime.request<{ record?: Record<string, any> }>('GET', \`/entity/${ingress.appId}/${ingress.entity.id}/row/\${rowId}?policy_id=\${encodeURIComponent(ingressId)}\`)
386
+ return mapRow(data.record ?? {})
387
+ },`)
388
+ }
389
+
390
+ if (hasAction(ingress, 'add')) {
391
+ methodChunks.push(` async prepareCreate(): Promise<Record<string, any>> {
392
+ return runtime.request<Record<string, any>>('POST', \`/entity/${ingress.appId}/${ingress.entity.id}/prepare\`, {
393
+ policy_id: ingressId,
394
+ })
395
+ },
396
+
397
+ async create(fields: ${typeName}CreateInput): Promise<number> {
398
+ return runtime.request<number>('POST', \`/entity/${ingress.appId}/${ingress.entity.id}/insert\`, {
399
+ fields: toArcubaseFields(fields, fieldIds),
400
+ policy_id: ingressId,
401
+ })
402
+ },`)
403
+ }
404
+
405
+ if (hasAction(ingress, 'edit')) {
406
+ methodChunks.push(` async update(rowId: string | number, fields: ${typeName}UpdateInput): Promise<boolean> {
407
+ const data = toArcubaseFields(fields, fieldIds)
408
+ return runtime.request<boolean>('PUT', \`/entity/${ingress.appId}/${ingress.entity.id}/row/\${rowId}\`, {
409
+ data,
410
+ changed_fields: Object.keys(data).map((fieldId) => Number(fieldId)),
411
+ policy_id: ingressId,
412
+ })
413
+ },`)
414
+ }
415
+
416
+ if (hasAction(ingress, 'delete')) {
417
+ methodChunks.push(` async delete(selection: ${typeName}Selection): Promise<{ deleted?: number } | boolean> {
418
+ return runtime.request<{ deleted?: number } | boolean>('POST', \`/entity/${ingress.appId}/${ingress.entity.id}/delete-item\`, {
419
+ policy_id: ingressId,
420
+ selection: toArcubaseSelection(selection),
421
+ })
422
+ },`)
423
+ }
424
+
425
+ if (hasAction(ingress, 'bulk_update')) {
426
+ methodChunks.push(` async bulkUpdate(selection: ${typeName}Selection, fields: ${typeName}BulkUpdateField[]): Promise<{ updated: number }> {
427
+ return runtime.request<{ updated: number }>('POST', \`/entity/${ingress.appId}/${ingress.entity.id}/bulk-update\`, {
428
+ policy_id: ingressId,
429
+ selection: toArcubaseSelection(selection),
430
+ fields: fields.map((field) => ({
431
+ id: fieldIds[field.key],
432
+ action: field.action,
433
+ })),
434
+ })
435
+ },
436
+
437
+ async saveBulkUpdate(input: { name: string; subject: string; fields: ${typeName}BulkUpdateField[] }): Promise<string> {
438
+ return runtime.request<string>('POST', \`/entity/${ingress.appId}/${ingress.entity.id}/bulk-update-save-as\`, {
439
+ name: input.name,
440
+ subject: input.subject,
441
+ fields: input.fields.map((field) => ({
442
+ id: fieldIds[field.key],
443
+ action: field.action,
444
+ })),
445
+ })
446
+ },`)
447
+ }
448
+
449
+ if (hasAction(ingress, 'export')) {
450
+ methodChunks.push(` async exportRows(options: ${typeName}ExportOptions = {}): Promise<{ task_id: string }> {
451
+ const fieldKeys = options.fields ?? Object.keys(fieldIds) as ${typeName}FieldKey[]
452
+ return runtime.request<{ task_id: string }>('POST', \`/entity/${ingress.appId}/${ingress.entity.id}/export\`, {
453
+ policy_id: ingressId,
454
+ props: options.props ?? [],
455
+ fields: fieldKeys.map((key) => fieldIds[key]),
456
+ propnames: options.propNames ?? {},
457
+ options: { has_id: options.hasIdField ?? false },
458
+ })
459
+ },
460
+
461
+ async exportTask(taskId: string): Promise<Record<string, any>> {
462
+ return runtime.request<Record<string, any>>('GET', \`/export-task/\${taskId}\`)
463
+ },`)
464
+ }
465
+
466
+ if (hasAction(ingress, 'batch_print')) {
467
+ methodChunks.push(` async batchPrint(selection: ${typeName}Selection, params: { limit?: number; offset?: number } = {}): Promise<{ items?: Record<string, any>[]; total?: number }> {
468
+ return runtime.request<{ items?: Record<string, any>[]; total?: number }>('POST', \`/entity/${ingress.appId}/${ingress.entity.id}/selection-query/batch_print\`, {
469
+ ...params,
470
+ policy_id: ingressId,
471
+ selection: toArcubaseSelection(selection),
472
+ })
473
+ },`)
474
+ }
475
+
476
+ if (hasAction(ingress, 'archive')) {
477
+ methodChunks.push(` async archive(selection: ${typeName}Selection): Promise<{ archived?: number } | boolean> {
478
+ return runtime.request<{ archived?: number } | boolean>('POST', \`/entity/${ingress.appId}/${ingress.entity.id}/selection-query/archive\`, {
479
+ policy_id: ingressId,
480
+ selection: toArcubaseSelection(selection),
481
+ })
482
+ },`)
483
+ }
484
+
485
+ if (hasAction(ingress, 'tag_manage')) {
486
+ methodChunks.push(` tags: {
487
+ async list(): Promise<string[]> {
488
+ return runtime.request<string[]>('GET', \`/user-action-entity/${ingress.appId}/${ingress.entity.id}/tag-names\`)
489
+ },
490
+ async add(selection: ${typeName}Selection, tagNames: string[]): Promise<Record<string, any>> {
491
+ return runtime.request<Record<string, any>>('POST', \`/user-action-entity/${ingress.appId}/${ingress.entity.id}/batch-add-tags\`, {
492
+ policy_id: ingressId,
493
+ selection: toArcubaseSelection(selection),
494
+ tag_names: tagNames,
495
+ })
496
+ },
497
+ async remove(selection: ${typeName}Selection, tagNames: string[]): Promise<boolean> {
498
+ return runtime.request<boolean>('POST', \`/user-action-entity/${ingress.appId}/${ingress.entity.id}/batch-remove-tags\`, {
499
+ policy_id: ingressId,
500
+ selection: toArcubaseSelection(selection),
501
+ tag_names: tagNames,
502
+ })
503
+ },
504
+ async selection(selection: ${typeName}Selection): Promise<string[]> {
505
+ return runtime.request<string[]>('POST', \`/user-action-entity/${ingress.appId}/${ingress.entity.id}/get-selection-tags\`, {
506
+ policy_id: ingressId,
507
+ selection: toArcubaseSelection(selection),
508
+ })
509
+ },
510
+ },`)
511
+ }
512
+
513
+ const customActions = ingress.actions.filter((action) => !['view', 'add', 'edit', 'delete', 'bulk_update', 'export', 'batch_print', 'archive', 'tag_manage'].includes(action))
514
+ if (customActions.length > 0) {
515
+ methodChunks.push(` async action(action: ${typeName}CustomAction, payload: Record<string, any> = {}): Promise<Record<string, any>> {
516
+ if (!allowedActions.includes(action)) {
517
+ throw new Error(\`Action is not allowed for ${ingress.key}: \${action}\`)
518
+ }
519
+ return runtime.request<Record<string, any>>('POST', \`/entity/${ingress.appId}/${ingress.entity.id}/selection-query/\${encodeURIComponent(action)}\`, {
520
+ ...payload,
521
+ policy_id: ingressId,
522
+ selection: payload.selection ? toArcubaseSelection(payload.selection) : undefined,
523
+ })
524
+ },`)
525
+ }
526
+
527
+ return `import {
528
+ fromArcubaseFields,
529
+ mapArcubaseCondition,
530
+ mapArcubaseSorts,
531
+ toArcubaseFields,
532
+ type ArcubaseRow,
533
+ type ArcubaseRuntime,
534
+ } from '../runtime.js'
535
+
536
+ export type ${typeName}FieldKey = ${fields.map((field) => `'${field.key}'`).join(' | ')}
244
537
 
245
538
  export type ${typeName}Fields = {
246
539
  ${fieldLines.join('\n')}
@@ -248,113 +541,126 @@ ${fieldLines.join('\n')}
248
541
 
249
542
  export type ${typeName}CreateInput = ${typeName}Fields
250
543
  export type ${typeName}UpdateInput = Partial<${typeName}Fields>
544
+ export type ${typeName}Action = ${ingress.actions.map((action) => `'${action}'`).join(' | ') || 'never'}
545
+ export type ${typeName}CustomAction = ${customActions.map((action) => `'${action}'`).join(' | ') || 'never'}
251
546
 
252
- const entityId = ${entity.id}
253
- const fieldIds = {
254
- ${fieldMapLines.join('\n')}
255
- } as const
547
+ export type ${typeName}Sort = {
548
+ column: ${typeName}FieldKey | 'id' | 'created_at' | 'updated_at' | 'creator' | string
549
+ order: 'asc' | 'desc'
550
+ }
256
551
 
257
- function toArcubaseFields(fields: Partial<${typeName}Fields>): Record<string, any> {
258
- const out: Record<string, any> = {}
259
- for (const [key, value] of Object.entries(fields)) {
260
- if (value === undefined) continue
261
- const fieldId = fieldIds[key as keyof typeof fieldIds]
262
- if (!fieldId) {
263
- throw new Error(\`Unknown ${entity.sdkName} field: \${key}\`)
264
- }
265
- out[String(fieldId)] = value
266
- }
267
- return out
552
+ export type ${typeName}QueryParams = {
553
+ limit?: number
554
+ offset?: number
555
+ search?: Record<string, any>
556
+ sorts?: ${typeName}Sort[]
557
+ view_mode?: 'normal' | 'deleted' | 'archived'
558
+ withSubForm?: boolean
268
559
  }
269
560
 
270
- function fromArcubaseFields(raw: Record<string, any> | undefined): ${typeName}Fields {
271
- const out: Record<string, any> = {}
272
- const source = raw ?? {}
273
- for (const [key, fieldId] of Object.entries(fieldIds)) {
274
- if (String(fieldId) in source) {
275
- out[key] = source[String(fieldId)]
276
- }
277
- }
278
- return out as ${typeName}Fields
561
+ export type ${typeName}Selection = {
562
+ type: 'ids' | 'condition'
563
+ length?: number
564
+ ids?: Array<number | string>
565
+ condition?: Record<string, any>
566
+ sorts?: ${typeName}Sort[]
567
+ }
568
+
569
+ export type ${typeName}BulkUpdateField = {
570
+ key: ${typeName}FieldKey
571
+ action: Record<string, any>
279
572
  }
280
573
 
574
+ export type ${typeName}ExportOptions = {
575
+ fields?: ${typeName}FieldKey[]
576
+ props?: string[]
577
+ propNames?: Record<string, string>
578
+ hasIdField?: boolean
579
+ }
580
+
581
+ const ingressId = ${JSON.stringify(ingress.hashId)}
582
+ const fieldIds = {
583
+ ${fieldMapLines.join('\n')}
584
+ } as const
585
+ const allowedActions = ${JSON.stringify(customActions)} as const
586
+
281
587
  function mapRow(raw: Record<string, any>): ArcubaseRow<${typeName}Fields> {
282
588
  return {
283
589
  id: Number(raw.id),
284
590
  title: typeof raw.title === 'string' ? raw.title : undefined,
285
- fields: fromArcubaseFields(raw.fields),
591
+ fields: fromArcubaseFields<${typeName}Fields>(raw.fields, fieldIds),
286
592
  raw,
287
593
  }
288
594
  }
289
595
 
290
- export function create${typeName}SDK(runtime: ArcubaseRuntime) {
596
+ function toArcubaseSelection(selection: ${typeName}Selection): Record<string, any> {
291
597
  return {
292
- async create(fields: ${typeName}CreateInput): Promise<number> {
293
- return runtime.request<number>('POST', \`/entity/${entity.appId}/\${entityId}/insert\`, {
294
- fields: toArcubaseFields(fields),
295
- })
296
- },
297
-
298
- async update(rowId: string | number, fields: ${typeName}UpdateInput): Promise<boolean> {
299
- const data = toArcubaseFields(fields)
300
- return runtime.request<boolean>('PUT', \`/entity/${entity.appId}/\${entityId}/row/\${rowId}\`, {
301
- data,
302
- changed_fields: Object.keys(data).map((fieldId) => Number(fieldId)),
303
- })
304
- },
305
-
306
- async get(rowId: string | number): Promise<ArcubaseRow<${typeName}Fields>> {
307
- const data = await runtime.request<{ record?: Record<string, any> }>('GET', \`/entity/${entity.appId}/\${entityId}/row/\${rowId}\`)
308
- return mapRow(data.record ?? {})
309
- },
310
-
311
- async query(params: { limit?: number; offset?: number; search?: Record<string, any> } = {}): Promise<{ items: ArcubaseRow<${typeName}Fields>[]; total?: number }> {
312
- const data = await runtime.request<{ items?: Record<string, any>[]; total?: number }>('POST', \`/entity/${entity.appId}/\${entityId}/data-query\`, params)
313
- return {
314
- items: (data.items ?? []).map(mapRow),
315
- total: data.total,
316
- }
317
- },
598
+ ...selection,
599
+ condition: mapArcubaseCondition(selection.condition ?? {}, fieldIds),
600
+ sorts: mapArcubaseSorts(selection.sorts, fieldIds),
318
601
  }
319
602
  }
603
+
604
+ export function create${typeName}Ingress(runtime: ArcubaseRuntime) {
605
+ return {
606
+ ${methodLines(methodChunks)} }
607
+ }
320
608
  `
321
609
  }
322
610
 
323
- function emitClient(entities: ArcubaseEntity[]): string {
324
- const imports = entities.map((entity) => {
325
- const typeName = toPascalCase(entity.sdkName)
326
- return `import { create${typeName}SDK } from './entities/${entity.sdkName}.js'`
611
+ function emitClient(ingresses: ArcubaseIngress[]): string {
612
+ const imports = ingresses.map((ingress) => {
613
+ const typeName = toPascalCase(ingress.key)
614
+ return `import { create${typeName}Ingress } from './ingresses/${ingress.key}.js'`
327
615
  })
328
- const properties = entities.map((entity) => ` ${entity.sdkName}: create${toPascalCase(entity.sdkName)}SDK(runtime),`)
329
- return `import { createRuntime, type ArcubaseSdkConfig } from './runtime.js'
616
+ const mapLines = ingresses.map((ingress) => ` ${JSON.stringify(ingress.key)}: ReturnType<typeof create${toPascalCase(ingress.key)}Ingress>`)
617
+ const cases = ingresses.map((ingress) => ` case ${JSON.stringify(ingress.key)}:
618
+ return create${toPascalCase(ingress.key)}Ingress(runtime) as ArcubaseIngressMap[K]`)
619
+ return `import { createRuntime, type ArcubaseClientConfig } from './runtime.js'
330
620
  ${imports.join('\n')}
331
621
 
332
- export function createArcubaseSdk(config: ArcubaseSdkConfig) {
622
+ export type ArcubaseIngressMap = {
623
+ ${mapLines.join('\n')}
624
+ }
625
+
626
+ export type ArcubaseIngressKey = keyof ArcubaseIngressMap
627
+
628
+ export function createArcubaseClient(config: ArcubaseClientConfig) {
333
629
  const runtime = createRuntime(config)
334
630
  return {
335
- ${properties.join('\n')}
631
+ ingress<K extends ArcubaseIngressKey>(key: K): ArcubaseIngressMap[K] {
632
+ switch (key) {
633
+ ${cases.join('\n')}
634
+ default:
635
+ throw new Error(\`Unknown Arcubase ingress: \${String(key)}\`)
636
+ }
637
+ },
336
638
  }
337
639
  }
338
640
  `
339
641
  }
340
642
 
341
- function emitIndex(entities: ArcubaseEntity[]): string {
342
- return `export { createArcubaseSdk } from './client.js'
343
- export type { ArcubaseSdkConfig, ArcubaseRow } from './runtime.js'
643
+ function emitIndex(ingresses: ArcubaseIngress[]): string {
644
+ return `export { createArcubaseClient } from './client.js'
645
+ export type { ArcubaseClientConfig, ArcubaseRow } from './runtime.js'
344
646
  export { arcubaseSchema } from './schema.js'
345
- ${entities.map((entity) => `export type { ${toPascalCase(entity.sdkName)}Fields, ${toPascalCase(entity.sdkName)}CreateInput, ${toPascalCase(entity.sdkName)}UpdateInput } from './entities/${entity.sdkName}.js'`).join('\n')}
647
+ export type { ArcubaseIngressKey, ArcubaseIngressMap } from './client.js'
648
+ ${ingresses.map((ingress) => {
649
+ const typeName = toPascalCase(ingress.key)
650
+ return `export type { ${typeName}Fields, ${typeName}CreateInput, ${typeName}UpdateInput, ${typeName}FieldKey, ${typeName}Selection } from './ingresses/${ingress.key}.js'`
651
+ }).join('\n')}
346
652
  `
347
653
  }
348
654
 
349
655
  export function generateArcubaseProjectSDK(payload: unknown): GeneratedSDK {
350
- const entities = normalizeEntities(payload)
656
+ const ingresses = normalizeIngresses(payload)
351
657
  return {
352
658
  files: [
353
659
  { path: 'runtime.ts', contents: emitRuntime() },
354
- { path: 'schema.ts', contents: emitSchema(entities) },
355
- ...entities.map((entity) => ({ path: `entities/${entity.sdkName}.ts`, contents: emitEntity(entity) })),
356
- { path: 'client.ts', contents: emitClient(entities) },
357
- { path: 'index.ts', contents: emitIndex(entities) },
660
+ { path: 'schema.ts', contents: emitSchema(ingresses) },
661
+ ...ingresses.map((ingress) => ({ path: `ingresses/${ingress.key}.ts`, contents: emitIngress(ingress) })),
662
+ { path: 'client.ts', contents: emitClient(ingresses) },
663
+ { path: 'index.ts', contents: emitIndex(ingresses) },
358
664
  ],
359
665
  }
360
666
  }