Initial commit: Add project files
ci / backend (push) Failing after 12s
ci / frontend (push) Failing after 19s
ci / static (push) Failing after 45s

This commit is contained in:
2026-08-02 05:15:36 +09:00
commit dcd1322d41
636 changed files with 122352 additions and 0 deletions
@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { StandardScreenState } from '../ui/contracts/screenContract'
import PageLayout from '../ui/layouts/PageLayout.vue'
import FormPageLayout from '../ui/layouts/FormPageLayout.vue'
import StandardScreenBoundary from '../ui/screen-types/v2/StandardScreenBoundary.vue'
withDefaults(defineProps<{ title: string; subtitle?: string; state?: StandardScreenState; dirty?: boolean; readonly?: boolean; asOf?: string; version?: string }>(), { state: 'READY', dirty: false, readonly: false })
const emit = defineEmits<{ retry: []; submit: []; cancel: [] }>()
</script>
<template>
<PageLayout :title="title" :subtitle="subtitle" :status="readonly ? 'READONLY' : state" :as-of="asOf" :version="version">
<StandardScreenBoundary :state="readonly ? 'READONLY' : (dirty ? 'DIRTY' : state)" :stale-at="asOf" @retry="emit('retry')">
<FormPageLayout @submit="emit('submit')">
<slot />
<template v-if="$slots.aside" #preview><slot name="aside" /></template>
</FormPageLayout>
</StandardScreenBoundary>
<template #footer><slot name="actions" /></template>
</PageLayout>
</template>
@@ -0,0 +1,36 @@
<script setup lang="ts">
import type { UiGridColumn } from '../ui/adapter/contracts'
import type { StandardScreenState } from '../ui/contracts/screenContract'
import PageLayout from '../ui/layouts/PageLayout.vue'
import StandardScreenBoundary from '../ui/screen-types/v2/StandardScreenBoundary.vue'
import KsDataGrid from '../ui/components/KsDataGrid.vue'
import KsPaginator from '../ui/components/KsPaginator.vue'
withDefaults(defineProps<{
title: string
subtitle?: string
state?: StandardScreenState
rows: unknown[]
columns: UiGridColumn[]
page: number
pageSize: number
total: number
asOf?: string
version?: string
warning?: string
}>(), { state: 'READY', rows: () => [] })
const emit = defineEmits<{ retry: []; rowSelected: [row: unknown]; pageChange: [value: { page: number; pageSize: number }] }>()
</script>
<template>
<PageLayout :title="title" :subtitle="subtitle" :status="state" :as-of="asOf" :version="version">
<template #actions><slot name="actions" /></template>
<template #summary><slot name="summary" /></template>
<template #filters><slot name="filters" /></template>
<StandardScreenBoundary :state="state" :warning="warning" :stale-at="asOf" @retry="emit('retry')">
<KsDataGrid :rows="rows" :columns="columns" @row-selected="emit('rowSelected', $event)" />
<KsPaginator :page="page" :page-size="pageSize" :total="total" @page-change="emit('pageChange', $event)" />
</StandardScreenBoundary>
<template v-if="$slots.detail" #aside><slot name="detail" /></template>
<template v-if="$slots.footer" #footer><slot name="footer" /></template>
</PageLayout>
</template>
+36
View File
@@ -0,0 +1,36 @@
import type { ZodType } from 'zod'
import type { UiGridColumn, UiGridFilter, UiGridSort } from '../ui/adapter/contracts'
export type CrudPermission = 'read' | 'create' | 'update' | 'delete' | 'export' | 'review' | 'publish'
export interface CrudListQuery {
page: number
pageSize: number
search?: string
sorts: UiGridSort[]
filters: UiGridFilter[]
}
export interface CrudPageResult<T> {
items: T[]
total: number
asOf: string
projectionVersion: string
watermark?: string
stale: boolean
}
export interface CrudMutationContext {
idempotencyKey: string
ifMatch?: string
reason?: string
correlationId?: string
}
export interface CrudResourceContract<TItem, TForm> {
resourceCode: string
routeBase: string
queryKey: readonly string[]
columns: UiGridColumn[]
formSchema: ZodType<TForm>
permissions: Partial<Record<CrudPermission, string>>
defaultQuery: CrudListQuery
parseListResponse(payload: unknown): CrudPageResult<TItem>
parseDetailResponse(payload: unknown): TItem
}
+36
View File
@@ -0,0 +1,36 @@
import type { CrudListQuery } from './contracts'
const positiveInt = (value: string | null, fallback: number): number => {
const parsed = Number(value)
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback
}
export function decodeCrudQuery(params: URLSearchParams, fallback: CrudListQuery): CrudListQuery {
const sorts = params.getAll('sort').flatMap(value => {
const [field, direction] = value.split(':')
return field && (direction === 'asc' || direction === 'desc') ? [{ field, direction }] : []
})
const filters = params.getAll('filter').flatMap(value => {
const first = value.indexOf(':')
const second = value.indexOf(':', first + 1)
if (first <= 0 || second <= first) return []
return [{ field: value.slice(0, first), operator: value.slice(first + 1, second), value: value.slice(second + 1) }]
})
return {
page: positiveInt(params.get('page'), fallback.page),
pageSize: positiveInt(params.get('pageSize'), fallback.pageSize),
search: params.get('search')?.trim() || undefined,
sorts: sorts.length ? sorts : fallback.sorts,
filters: filters.length ? filters : fallback.filters
}
}
export function encodeCrudQuery(query: CrudListQuery): URLSearchParams {
const params = new URLSearchParams()
params.set('page', String(query.page))
params.set('pageSize', String(query.pageSize))
if (query.search) params.set('search', query.search)
for (const sort of query.sorts) params.append('sort', `${sort.field}:${sort.direction}`)
for (const filter of query.filters) params.append('filter', `${filter.field}:${filter.operator}:${String(filter.value ?? '')}`)
return params
}
@@ -0,0 +1,22 @@
import type { ZodType } from 'zod'
import type { UiGridColumn } from '../ui/adapter/contracts'
export type CrudConcurrencyMode = 'NONE' | 'ETAG_IF_MATCH'
export type CrudIdempotencyMode = 'NONE' | 'IDEMPOTENCY_KEY'
export interface CrudResourceDefinition<TQuery, TRow, TForm> {
resourceId: string
querySchemaVersion: string
responseSchemaVersion: string
querySchema: ZodType<TQuery>
rowSchema: ZodType<TRow>
formSchema: ZodType<TForm>
columns: readonly UiGridColumn[]
sensitiveFields: readonly string[]
permissionPolicy: string
concurrencyMode: CrudConcurrencyMode
idempotencyMode: CrudIdempotencyMode
}
export function assertCrudResourceDefinition(definition: CrudResourceDefinition<unknown, unknown, unknown>): void {
if (!definition.resourceId.trim()) throw new Error('resourceId is required')
const fields = new Set(definition.columns.map(x => x.field))
for (const sensitive of definition.sensitiveFields) if (!fields.has(sensitive)) throw new Error(`Sensitive field '${sensitive}' has no grid column contract`)
}
@@ -0,0 +1,12 @@
import { describe, expect, it } from 'vitest'
import { decodeCrudQuery, encodeCrudQuery } from '../queryCodec'
const fallback = { page: 1, pageSize: 20, sorts: [], filters: [] }
describe('CRUD URL codec', () => {
it('round-trips paging, sort, filter and search without hidden Pinia state', () => {
const source = { page: 3, pageSize: 50, search: '005930', sorts: [{ field: 'asOf', direction: 'desc' as const }], filters: [{ field: 'status', operator: 'eq', value: 'WARN' }] }
expect(decodeCrudQuery(encodeCrudQuery(source), fallback)).toEqual(source)
})
it('fails closed to approved defaults for invalid paging', () => {
expect(decodeCrudQuery(new URLSearchParams('page=0&pageSize=-1'), fallback)).toEqual({ ...fallback, search: undefined })
})
})
@@ -0,0 +1,24 @@
import { computed, ref } from 'vue'
import type { CrudListQuery } from './contracts'
export function useCrudListState(initial: CrudListQuery) {
const query = ref<CrudListQuery>({ ...initial, sorts: [...initial.sorts], filters: [...initial.filters] })
const selectedId = ref<string | number | null>(null)
const dirty = ref(false)
function replace(next: CrudListQuery): void { query.value = { ...next, sorts: [...next.sorts], filters: [...next.filters] } }
function setPage(page: number, pageSize = query.value.pageSize): void { query.value = { ...query.value, page, pageSize } }
function setSearch(search?: string): void { query.value = { ...query.value, page: 1, search: search?.trim() || undefined } }
function reset(): void { replace(initial); selectedId.value = null; dirty.value = false }
return {
query,
selectedId,
dirty,
offset: computed(() => (query.value.page - 1) * query.value.pageSize),
replace,
setPage,
setSearch,
reset
}
}
@@ -0,0 +1,21 @@
import { ref } from 'vue'
import { createIdempotencyKey } from '../commands/idempotency'
export interface OptimisticCommandRequest<T> { payload: T; etag?: string }
export interface OptimisticCommandResponse<TResult> { data: TResult; etag?: string; correlationId?: string }
export function useOptimisticCommand<TPayload, TResult>(execute: (request: OptimisticCommandRequest<TPayload>, headers: Record<string,string>) => Promise<OptimisticCommandResponse<TResult>>) {
const pending = ref(false); const conflict = ref(false); const lastCorrelationId = ref<string>()
async function run(request: OptimisticCommandRequest<TPayload>): Promise<OptimisticCommandResponse<TResult>> {
if (pending.value) throw new Error('Command is already in progress')
pending.value=true; conflict.value=false
try {
const headers: Record<string,string> = { 'Idempotency-Key': createIdempotencyKey() }
if (request.etag) headers['If-Match']=request.etag
const response=await execute(request,headers); lastCorrelationId.value=response.correlationId; return response
} catch (error: unknown) {
const status=(error as { response?: { status?: number } })?.response?.status
if (status===409 || status===412) conflict.value=true
throw error
} finally { pending.value=false }
}
return { pending, conflict, lastCorrelationId, run }
}