Add a core package to be reused by desktop and server

This commit is contained in:
Hakan Shehu
2024-11-07 09:15:00 +01:00
parent 00f6ac971e
commit 8a5409ad29
277 changed files with 1193 additions and 1089 deletions

View File

@@ -22,5 +22,10 @@
"prettier": "^3.3.3",
"typescript": "^5.6.3",
"vitest": "^1.6.0"
},
"dependencies": {
"diff": "^7.0.0",
"yjs": "^13.6.20",
"zod": "^3.23.8"
}
}

313
packages/core/src/crdt.ts Normal file
View File

@@ -0,0 +1,313 @@
import { z } from 'zod';
import * as Y from 'yjs';
import { ZodText } from './registry/zod';
import { isEqual } from 'lodash';
import { diffChars } from 'diff';
export const applyCrdt = (
schema: z.ZodSchema,
attributes: z.infer<typeof schema>,
attributesMap: Y.Map<any>
) => {
if (!(schema instanceof z.ZodObject)) {
throw new Error('Schema must be a ZodObject');
}
applyObjectChanges(schema, attributes, attributesMap);
const parseResult = schema.safeParse(attributesMap.toJSON());
if (!parseResult.success) {
throw new Error('Invalid attributes', parseResult.error);
}
};
const applyObjectChanges = (
schema: z.ZodObject<any, any, any, any>,
attributes: any,
yMap: Y.Map<any>
) => {
for (const [key, value] of Object.entries(attributes)) {
if (value === null) {
yMap.set(key, null);
continue;
}
const schemaField = extractType(schema.shape[key], value);
if (schemaField instanceof z.ZodObject) {
if (typeof value !== 'object') {
throw new Error('Value must be an object');
}
let nestedMap = yMap.get(key);
if (!(nestedMap instanceof Y.Map)) {
nestedMap = new Y.Map();
yMap.set(key, nestedMap);
}
applyObjectChanges(schemaField, value, nestedMap);
} else if (schemaField instanceof z.ZodRecord) {
if (typeof value !== 'object') {
throw new Error('Value must be an object');
}
let nestedMap = yMap.get(key);
if (!(nestedMap instanceof Y.Map)) {
nestedMap = new Y.Map();
yMap.set(key, nestedMap);
}
applyRecordChanges(schemaField, value, nestedMap);
} else if (schemaField instanceof z.ZodArray) {
if (!Array.isArray(value)) {
throw new Error('Value must be an array');
}
let yArray = yMap.get(key);
if (!(yArray instanceof Y.Array)) {
yArray = new Y.Array();
yMap.set(key, yArray);
}
applyArrayChanges(schemaField, value, yArray);
} else if (schemaField instanceof ZodText) {
if (typeof value !== 'string') {
throw new Error('Value must be a string');
}
let yText = yMap.get(key);
if (!(yText instanceof Y.Text)) {
yText = new Y.Text();
yMap.set(key, yText);
}
applyTextChanges(value, yText);
} else {
const currentValue = yMap.get(key);
if (!isEqual(currentValue, value)) {
yMap.set(key, value);
}
}
}
const deletedKeys = Array.from(yMap.keys()).filter(
(key) => !attributes.hasOwnProperty(key)
);
for (const key of deletedKeys) {
yMap.delete(key);
}
};
const applyArrayChanges = (
schemaField: z.ZodArray<any>,
value: Array<any>,
yArray: Y.Array<any>
) => {
const itemSchema = extractType(schemaField.element, value);
const length = value.length;
for (let i = 0; i < length; i++) {
const item = value[i];
if (item === null) {
yArray.delete(i, 1);
yArray.insert(i, [null]);
continue;
}
if (itemSchema instanceof z.ZodObject) {
if (yArray.length <= i) {
const nestedMap = new Y.Map();
yArray.insert(i, [nestedMap]);
}
let nestedMap = yArray.get(i);
if (!(nestedMap instanceof Y.Map)) {
nestedMap = new Y.Map();
yArray.delete(i, 1);
yArray.insert(i, [nestedMap]);
}
applyObjectChanges(itemSchema, item, nestedMap);
} else if (itemSchema instanceof z.ZodRecord) {
if (yArray.length <= i) {
const nestedMap = new Y.Map();
yArray.insert(i, [nestedMap]);
}
let nestedMap = yArray.get(i);
if (!(nestedMap instanceof Y.Map)) {
nestedMap = new Y.Map();
yArray.delete(i, 1);
yArray.insert(i, [nestedMap]);
}
applyRecordChanges(itemSchema, item, nestedMap);
} else if (itemSchema instanceof z.ZodRecord) {
if (yArray.length <= i) {
const nestedMap = new Y.Map();
yArray.insert(i, [nestedMap]);
}
let nestedMap = yArray.get(i);
if (!(nestedMap instanceof Y.Map)) {
nestedMap = new Y.Map();
yArray.delete(i, 1);
yArray.insert(i, [nestedMap]);
}
applyRecordChanges(itemSchema, item, nestedMap);
} else if (itemSchema instanceof ZodText) {
if (yArray.length <= i) {
const yText = new Y.Text();
yArray.insert(i, [yText]);
}
let yText = yArray.get(i);
if (!(yText instanceof Y.Text)) {
yText = new Y.Text();
yArray.delete(i, 1);
yArray.insert(i, [yText]);
}
applyTextChanges(item, yText);
} else {
if (yArray.length <= i) {
yArray.insert(i, [item]);
} else {
const currentItem = yArray.get(i);
if (!isEqual(currentItem, item)) {
yArray.delete(i);
yArray.insert(i, [item]);
}
}
}
}
if (yArray.length > length) {
yArray.delete(yArray.length, yArray.length - length);
}
};
const applyRecordChanges = (
schemaField: z.ZodRecord<any, any>,
record: Record<any, any>,
yMap: Y.Map<any>
) => {
const valueSchema = extractType(schemaField.valueSchema, record);
for (const [key, value] of Object.entries(record)) {
if (value === null) {
yMap.set(key, null);
continue;
}
if (valueSchema instanceof z.ZodObject) {
if (typeof value !== 'object') {
throw new Error('Value must be an object');
}
let nestedMap = yMap.get(key);
if (!(nestedMap instanceof Y.Map)) {
nestedMap = new Y.Map();
yMap.set(key, nestedMap);
}
applyObjectChanges(valueSchema, value, nestedMap);
} else if (valueSchema instanceof z.ZodRecord) {
if (typeof value !== 'object') {
throw new Error('Value must be an object');
}
let nestedMap = yMap.get(key);
if (!(nestedMap instanceof Y.Map)) {
nestedMap = new Y.Map();
yMap.set(key, nestedMap);
}
applyRecordChanges(valueSchema, value, nestedMap);
} else if (valueSchema instanceof z.ZodArray) {
if (!Array.isArray(value)) {
throw new Error('Value must be an array');
}
let yArray = yMap.get(key);
if (!(yArray instanceof Y.Array)) {
yArray = new Y.Array();
yMap.set(key, yArray);
}
applyArrayChanges(valueSchema, value, yArray);
} else if (valueSchema instanceof ZodText) {
if (typeof value !== 'string') {
throw new Error('Value must be a string');
}
let yText = yMap.get(key);
if (!(yText instanceof Y.Text)) {
yText = new Y.Text();
yMap.set(key, yText);
}
applyTextChanges(value, yText);
} else {
const currentValue = yMap.get(key);
if (!isEqual(currentValue, value)) {
yMap.set(key, value);
}
}
}
const deletedKeys = Array.from(yMap.keys()).filter(
(key) => !record.hasOwnProperty(key)
);
for (const key of deletedKeys) {
yMap.delete(key);
}
};
const applyTextChanges = (value: string, yText: Y.Text) => {
const currentText = yText.toString();
const newText = value ? value.toString() : '';
if (!isEqual(currentText, newText)) {
const diffs = diffChars(currentText, newText);
let index = 0;
for (const diff of diffs) {
if (diff.added) {
yText.insert(index, diff.value);
index += diff.value.length;
} else if (diff.removed) {
yText.delete(index, diff.value.length);
index -= diff.value.length;
} else {
index += diff.value.length;
}
}
}
};
const extractType = (
schema: z.ZodType<any, any, any>,
value: any
): z.ZodType<any, any, any> => {
if (schema instanceof z.ZodOptional) {
return extractType(schema.unwrap(), value);
}
if (schema instanceof z.ZodNullable) {
return extractType(schema.unwrap(), value);
}
if (schema instanceof z.ZodUnion) {
for (const option of schema.options) {
if (option.safeParse(value).success) {
return extractType(option, value);
}
}
}
return schema;
};

View File

@@ -1,3 +1,303 @@
export const core = () => {
return 'core library';
import * as yjs from 'yjs';
import { z as zod } from 'zod';
import { applyCrdt } from './crdt';
import {
ChannelAttributes,
channelAttributesSchema,
channelModel,
} from './registry/channel';
import {
PageAttributes,
pageAttributesSchema,
pageModel,
} from './registry/page';
import {
ChatAttributes,
chatAttributesSchema,
chatModel,
} from './registry/chat';
import {
SpaceAttributes,
spaceAttributesSchema,
spaceModel,
} from './registry/space';
import {
UserAttributes,
userAttributesSchema,
userModel,
} from './registry/user';
import {
MessageAttributes,
messageAttributesSchema,
messageModel,
} from './registry/message';
import {
ViewFieldAttributes,
viewFieldAttributesSchema,
ViewFieldFilterAttributes,
viewFieldFilterAttributesSchema,
ViewGroupFilterAttributes,
viewGroupFilterAttributesSchema,
ViewFilterAttributes,
viewFilterAttributesSchema,
ViewSortAttributes,
viewSortAttributesSchema,
ViewAttributes,
viewAttributesSchema,
DatabaseAttributes,
databaseAttributesSchema,
databaseModel,
} from './registry/database';
import {
FileAttributes,
fileAttributesSchema,
fileModel,
} from './registry/file';
import {
FolderAttributes,
folderAttributesSchema,
folderModel,
} from './registry/folder';
import {
RecordAttributes,
recordAttributesSchema,
recordModel,
} from './registry/record';
import {
blockLeafSchema,
blockSchema,
BlockLeaf,
Block,
} from './registry/block';
import {
selectOptionAttributesSchema,
booleanFieldAttributesSchema,
booleanFieldValueSchema,
collaboratorFieldAttributesSchema,
collaboratorFieldValueSchema,
createdAtFieldAttributesSchema,
dateFieldAttributesSchema,
dateFieldValueSchema,
emailFieldAttributesSchema,
emailFieldValueSchema,
numberFieldAttributesSchema,
numberFieldValueSchema,
phoneFieldAttributesSchema,
phoneFieldValueSchema,
textFieldAttributesSchema,
textFieldValueSchema,
urlFieldAttributesSchema,
urlFieldValueSchema,
updatedAtFieldAttributesSchema,
updatedAtFieldValueSchema,
updatedByFieldAttributesSchema,
updatedByFieldValueSchema,
fieldAttributesSchema,
fieldValueSchema,
BooleanFieldAttributes,
BooleanFieldValue,
CollaboratorFieldAttributes,
CollaboratorFieldValue,
CreatedAtFieldAttributes,
CreatedAtFieldValue,
DateFieldAttributes,
DateFieldValue,
EmailFieldAttributes,
EmailFieldValue,
FileFieldAttributes,
FileFieldValue,
MultiSelectFieldAttributes,
MultiSelectFieldValue,
NumberFieldAttributes,
NumberFieldValue,
PhoneFieldAttributes,
PhoneFieldValue,
RelationFieldAttributes,
RelationFieldValue,
RollupFieldAttributes,
RollupFieldValue,
SelectFieldAttributes,
SelectFieldValue,
TextFieldAttributes,
TextFieldValue,
UrlFieldAttributes,
CreatedByFieldAttributes,
CreatedByFieldValue,
UpdatedByFieldAttributes,
FieldAttributes,
FieldValue,
FieldType,
SelectOptionAttributes,
UpdatedAtFieldAttributes,
UpdatedAtFieldValue,
createdAtFieldValueSchema,
createdByFieldAttributesSchema,
createdByFieldValueSchema,
fileFieldAttributesSchema,
fileFieldValueSchema,
multiSelectFieldAttributesSchema,
multiSelectFieldValueSchema,
relationFieldAttributesSchema,
relationFieldValueSchema,
rollupFieldAttributesSchema,
rollupFieldValueSchema,
selectFieldAttributesSchema,
selectFieldValueSchema,
} from './registry/fields';
import { NodeRole, NodeModel, NodeMutationContext } from './registry/core';
import {
registry,
NodeAttributes,
ChannelNode,
ChatNode,
DatabaseNode,
FileNode,
FolderNode,
MessageNode,
PageNode,
RecordNode,
SpaceNode,
UserNode,
Node,
} from './registry';
export {
yjs,
zod,
registry,
type ChannelAttributes,
channelAttributesSchema,
channelModel,
type PageAttributes,
pageAttributesSchema,
pageModel,
type ChatAttributes,
chatAttributesSchema,
chatModel,
type SpaceAttributes,
spaceAttributesSchema,
spaceModel,
type UserAttributes,
userAttributesSchema,
userModel,
type MessageAttributes,
messageAttributesSchema,
messageModel,
type ViewFieldAttributes,
viewFieldAttributesSchema,
type ViewFieldFilterAttributes,
viewFieldFilterAttributesSchema,
type ViewGroupFilterAttributes,
viewGroupFilterAttributesSchema,
type ViewFilterAttributes,
viewFilterAttributesSchema,
type ViewSortAttributes,
viewSortAttributesSchema,
type ViewAttributes,
viewAttributesSchema,
type DatabaseAttributes,
databaseAttributesSchema,
databaseModel,
type FileAttributes,
fileAttributesSchema,
fileModel,
type FolderAttributes,
folderAttributesSchema,
folderModel,
type RecordAttributes,
recordAttributesSchema,
recordModel,
blockLeafSchema,
blockSchema,
selectOptionAttributesSchema,
booleanFieldAttributesSchema,
booleanFieldValueSchema,
collaboratorFieldAttributesSchema,
collaboratorFieldValueSchema,
createdAtFieldAttributesSchema,
dateFieldAttributesSchema,
dateFieldValueSchema,
emailFieldAttributesSchema,
emailFieldValueSchema,
numberFieldAttributesSchema,
numberFieldValueSchema,
phoneFieldAttributesSchema,
phoneFieldValueSchema,
textFieldAttributesSchema,
textFieldValueSchema,
urlFieldAttributesSchema,
urlFieldValueSchema,
updatedAtFieldAttributesSchema,
updatedAtFieldValueSchema,
updatedByFieldAttributesSchema,
updatedByFieldValueSchema,
fieldAttributesSchema,
fieldValueSchema,
type BooleanFieldAttributes,
type BooleanFieldValue,
type CollaboratorFieldAttributes,
type CollaboratorFieldValue,
type CreatedAtFieldAttributes,
type CreatedAtFieldValue,
type DateFieldAttributes,
type DateFieldValue,
type EmailFieldAttributes,
type EmailFieldValue,
type FileFieldAttributes,
type FileFieldValue,
type MultiSelectFieldAttributes,
type MultiSelectFieldValue,
type NumberFieldAttributes,
type NumberFieldValue,
type PhoneFieldAttributes,
type PhoneFieldValue,
type RelationFieldAttributes,
type RelationFieldValue,
type RollupFieldAttributes,
type RollupFieldValue,
type SelectFieldAttributes,
type SelectFieldValue,
type TextFieldAttributes,
type TextFieldValue,
type UrlFieldAttributes,
type CreatedByFieldAttributes,
type CreatedByFieldValue,
type UpdatedByFieldAttributes,
type FieldAttributes,
type FieldValue,
type FieldType,
type SelectOptionAttributes,
type UpdatedAtFieldAttributes,
type UpdatedAtFieldValue,
createdAtFieldValueSchema,
createdByFieldAttributesSchema,
createdByFieldValueSchema,
fileFieldAttributesSchema,
fileFieldValueSchema,
multiSelectFieldAttributesSchema,
multiSelectFieldValueSchema,
relationFieldAttributesSchema,
relationFieldValueSchema,
rollupFieldAttributesSchema,
rollupFieldValueSchema,
selectFieldAttributesSchema,
selectFieldValueSchema,
type NodeRole,
type NodeModel,
type NodeMutationContext,
type NodeAttributes,
type ChannelNode,
type ChatNode,
type DatabaseNode,
type FileNode,
type FolderNode,
type MessageNode,
type PageNode,
type RecordNode,
type SpaceNode,
type UserNode,
type BlockLeaf,
type Block,
type Node,
applyCrdt,
};

View File

@@ -0,0 +1,28 @@
import { ZodText } from './zod';
import { z } from 'zod';
export const blockLeafSchema = z.object({
type: z.string(),
text: ZodText.create(),
marks: z
.array(
z.object({
type: z.string(),
attrs: z.record(z.any()).nullable(),
})
)
.nullable(),
});
export type BlockLeaf = z.infer<typeof blockLeafSchema>;
export const blockSchema = z.object({
id: z.string(),
type: z.string(),
parentId: z.string(),
content: z.array(blockLeafSchema).nullable(),
attrs: z.record(z.any()).nullable(),
index: z.string(),
});
export type Block = z.infer<typeof blockSchema>;

View File

@@ -0,0 +1,54 @@
import { z } from 'zod';
import { NodeModel } from './core';
import { isEqual } from 'lodash';
export const channelAttributesSchema = z.object({
type: z.literal('channel'),
name: z.string(),
avatar: z.string().nullable().optional(),
parentId: z.string(),
index: z.string(),
collaborators: z.record(z.string()).nullable().optional(),
});
export type ChannelAttributes = z.infer<typeof channelAttributesSchema>;
export const channelModel: NodeModel = {
type: 'channel',
schema: channelAttributesSchema,
canCreate: async (context, attributes) => {
if (attributes.type !== 'channel') {
return false;
}
if (context.ancestors.length !== 1) {
return false;
}
const parent = context.ancestors[0];
if (!parent || parent.type !== 'space') {
return false;
}
const collaboratorIds = Object.keys(attributes.collaborators ?? {});
if (collaboratorIds.length > 0 && !context.hasAdminAccess()) {
return false;
}
return context.hasEditorAccess();
},
canUpdate: async (context, node, attributes) => {
if (attributes.type !== 'channel' || node.type !== 'channel') {
return false;
}
if (!isEqual(node.attributes.collaborators, attributes.collaborators)) {
return context.hasAdminAccess();
}
return context.hasEditorAccess();
},
canDelete: async (context, _) => {
return context.hasEditorAccess();
},
};

View File

@@ -0,0 +1,37 @@
import { z } from 'zod';
import { NodeModel } from './core';
export const chatAttributesSchema = z.object({
type: z.literal('chat'),
parentId: z.string(),
collaborators: z.record(z.string()),
});
export type ChatAttributes = z.infer<typeof chatAttributesSchema>;
export const chatModel: NodeModel = {
type: 'chat',
schema: chatAttributesSchema,
canCreate: async (context, attributes) => {
if (attributes.type !== 'chat') {
return false;
}
const collaboratorIds = Object.keys(attributes.collaborators ?? {});
if (collaboratorIds.length !== 2) {
return false;
}
if (!collaboratorIds.includes(context.userId)) {
return false;
}
return true;
},
canUpdate: async () => {
return false;
},
canDelete: async () => {
return false;
},
};

View File

@@ -0,0 +1,31 @@
import { ZodSchema } from 'zod';
import { Node, NodeAttributes } from './';
export type NodeRole = 'admin' | 'editor' | 'collaborator' | 'viewer';
export interface NodeMutationContext {
accountId: string;
workspaceId: string;
userId: string;
ancestors: Node[];
role: NodeRole;
hasAdminAccess: () => boolean;
hasEditorAccess: () => boolean;
hasCollaboratorAccess: () => boolean;
hasViewerAccess: () => boolean;
}
export interface NodeModel {
type: string;
schema: ZodSchema;
canCreate: (
context: NodeMutationContext,
attributes: NodeAttributes
) => Promise<boolean>;
canUpdate: (
context: NodeMutationContext,
node: Node,
attributes: NodeAttributes
) => Promise<boolean>;
canDelete: (context: NodeMutationContext, node: Node) => Promise<boolean>;
}

View File

@@ -0,0 +1,111 @@
import { z } from 'zod';
import { NodeModel } from './core';
import { fieldAttributesSchema } from './fields';
import { isEqual } from 'lodash';
export const viewFieldAttributesSchema = z.object({
id: z.string(),
width: z.number().nullable().optional(),
display: z.boolean().nullable().optional(),
index: z.string().nullable().optional(),
});
export type ViewFieldAttributes = z.infer<typeof viewFieldAttributesSchema>;
export const viewFieldFilterAttributesSchema = z.object({
id: z.string(),
fieldId: z.string(),
type: z.literal('field'),
operator: z.string(),
value: z
.union([z.string(), z.number(), z.boolean(), z.array(z.string())])
.nullable(),
});
export type ViewFieldFilterAttributes = z.infer<
typeof viewFieldFilterAttributesSchema
>;
export const viewGroupFilterAttributesSchema = z.object({
id: z.string(),
type: z.literal('group'),
operator: z.enum(['and', 'or']),
filters: z.array(viewFieldFilterAttributesSchema),
});
export type ViewGroupFilterAttributes = z.infer<
typeof viewGroupFilterAttributesSchema
>;
export const viewSortAttributesSchema = z.object({
id: z.string(),
fieldId: z.string(),
direction: z.enum(['asc', 'desc']),
});
export type ViewSortAttributes = z.infer<typeof viewSortAttributesSchema>;
export const viewFilterAttributesSchema = z.discriminatedUnion('type', [
viewFieldFilterAttributesSchema,
viewGroupFilterAttributesSchema,
]);
export type ViewFilterAttributes = z.infer<typeof viewFilterAttributesSchema>;
export const viewAttributesSchema = z.object({
id: z.string(),
type: z.enum(['table', 'board', 'calendar']),
name: z.string(),
avatar: z.string().nullable(),
index: z.string(),
fields: z.record(z.string(), viewFieldAttributesSchema),
filters: z.record(z.string(), viewFilterAttributesSchema),
sorts: z.record(z.string(), viewSortAttributesSchema),
groupBy: z.string().nullable(),
nameWidth: z.number().nullable().optional(),
});
export type ViewAttributes = z.infer<typeof viewAttributesSchema>;
export const databaseAttributesSchema = z.object({
type: z.literal('database'),
name: z.string(),
avatar: z.string().nullable().optional(),
parentId: z.string(),
collaborators: z.record(z.string()).nullable().optional(),
fields: z.record(z.string(), fieldAttributesSchema),
views: z.record(z.string(), viewAttributesSchema),
});
export type DatabaseAttributes = z.infer<typeof databaseAttributesSchema>;
export const databaseModel: NodeModel = {
type: 'database',
schema: databaseAttributesSchema,
canCreate: async (context, attributes) => {
if (attributes.type !== 'database') {
return false;
}
const collaboratorIds = Object.keys(attributes.collaborators ?? {});
if (collaboratorIds.length > 0 && !context.hasAdminAccess()) {
return false;
}
return context.hasEditorAccess();
},
canUpdate: async (context, node, attributes) => {
if (attributes.type !== 'database' || node.type !== 'database') {
return false;
}
if (!isEqual(node.attributes.collaborators, attributes.collaborators)) {
return context.hasAdminAccess();
}
return context.hasEditorAccess();
},
canDelete: async (context, _) => {
return context.hasEditorAccess();
},
};

View File

@@ -0,0 +1,347 @@
import { z } from 'zod';
import { ZodText } from './zod';
export const selectOptionAttributesSchema = z.object({
id: z.string(),
name: z.string(),
color: z.string(),
index: z.string(),
});
export type SelectOptionAttributes = z.infer<
typeof selectOptionAttributesSchema
>;
export const booleanFieldAttributesSchema = z.object({
id: z.string(),
type: z.literal('boolean'),
name: z.string(),
index: z.string(),
});
export type BooleanFieldAttributes = z.infer<
typeof booleanFieldAttributesSchema
>;
export const booleanFieldValueSchema = z.object({
type: z.literal('boolean'),
value: z.boolean(),
});
export type BooleanFieldValue = z.infer<typeof booleanFieldValueSchema>;
export const collaboratorFieldAttributesSchema = z.object({
id: z.string(),
type: z.literal('collaborator'),
name: z.string(),
index: z.string(),
});
export type CollaboratorFieldAttributes = z.infer<
typeof collaboratorFieldAttributesSchema
>;
export const collaboratorFieldValueSchema = z.object({
type: z.literal('collaborator'),
value: z.array(z.string()),
});
export type CollaboratorFieldValue = z.infer<
typeof collaboratorFieldValueSchema
>;
export const createdAtFieldAttributesSchema = z.object({
id: z.string(),
type: z.literal('createdAt'),
name: z.string(),
index: z.string(),
});
export type CreatedAtFieldAttributes = z.infer<
typeof createdAtFieldAttributesSchema
>;
export const createdAtFieldValueSchema = z.object({
type: z.literal('createdAt'),
value: z.string(),
});
export type CreatedAtFieldValue = z.infer<typeof createdAtFieldValueSchema>;
export const createdByFieldAttributesSchema = z.object({
id: z.string(),
type: z.literal('createdBy'),
name: z.string(),
index: z.string(),
});
export type CreatedByFieldAttributes = z.infer<
typeof createdByFieldAttributesSchema
>;
export const createdByFieldValueSchema = z.object({
type: z.literal('createdBy'),
value: z.string(),
});
export type CreatedByFieldValue = z.infer<typeof createdByFieldValueSchema>;
export const dateFieldAttributesSchema = z.object({
id: z.string(),
type: z.literal('date'),
name: z.string(),
index: z.string(),
});
export type DateFieldAttributes = z.infer<typeof dateFieldAttributesSchema>;
export const dateFieldValueSchema = z.object({
type: z.literal('date'),
value: z.string(),
});
export type DateFieldValue = z.infer<typeof dateFieldValueSchema>;
export const emailFieldAttributesSchema = z.object({
id: z.string(),
type: z.literal('email'),
name: z.string(),
index: z.string(),
});
export type EmailFieldAttributes = z.infer<typeof emailFieldAttributesSchema>;
export const emailFieldValueSchema = z.object({
type: z.literal('email'),
value: z.string(),
});
export type EmailFieldValue = z.infer<typeof emailFieldValueSchema>;
export const fileFieldAttributesSchema = z.object({
id: z.string(),
type: z.literal('file'),
name: z.string(),
index: z.string(),
});
export type FileFieldAttributes = z.infer<typeof fileFieldAttributesSchema>;
export const fileFieldValueSchema = z.object({
type: z.literal('file'),
value: z.string(),
});
export type FileFieldValue = z.infer<typeof fileFieldValueSchema>;
export const multiSelectFieldAttributesSchema = z.object({
id: z.string(),
type: z.literal('multiSelect'),
name: z.string(),
index: z.string(),
options: z.record(z.string(), selectOptionAttributesSchema).optional(),
});
export type MultiSelectFieldAttributes = z.infer<
typeof multiSelectFieldAttributesSchema
>;
export const multiSelectFieldValueSchema = z.object({
type: z.literal('multiSelect'),
value: z.array(z.string()),
});
export type MultiSelectFieldValue = z.infer<typeof multiSelectFieldValueSchema>;
export const numberFieldAttributesSchema = z.object({
id: z.string(),
type: z.literal('number'),
name: z.string(),
index: z.string(),
});
export type NumberFieldAttributes = z.infer<typeof numberFieldAttributesSchema>;
export const numberFieldValueSchema = z.object({
type: z.literal('number'),
value: z.number(),
});
export type NumberFieldValue = z.infer<typeof numberFieldValueSchema>;
export const phoneFieldAttributesSchema = z.object({
id: z.string(),
type: z.literal('phone'),
name: z.string(),
index: z.string(),
});
export type PhoneFieldAttributes = z.infer<typeof phoneFieldAttributesSchema>;
export const phoneFieldValueSchema = z.object({
type: z.literal('phone'),
value: z.string(),
});
export type PhoneFieldValue = z.infer<typeof phoneFieldValueSchema>;
export const relationFieldAttributesSchema = z.object({
id: z.string(),
type: z.literal('relation'),
name: z.string(),
index: z.string(),
});
export type RelationFieldAttributes = z.infer<
typeof relationFieldAttributesSchema
>;
export const relationFieldValueSchema = z.object({
type: z.literal('relation'),
value: z.array(z.string()),
});
export type RelationFieldValue = z.infer<typeof relationFieldValueSchema>;
export const rollupFieldAttributesSchema = z.object({
id: z.string(),
type: z.literal('rollup'),
name: z.string(),
index: z.string(),
});
export type RollupFieldAttributes = z.infer<typeof rollupFieldAttributesSchema>;
export const rollupFieldValueSchema = z.object({
type: z.literal('rollup'),
value: z.string(),
});
export type RollupFieldValue = z.infer<typeof rollupFieldValueSchema>;
export const selectFieldAttributesSchema = z.object({
id: z.string(),
type: z.literal('select'),
name: z.string(),
index: z.string(),
options: z.record(z.string(), selectOptionAttributesSchema).optional(),
});
export type SelectFieldAttributes = z.infer<typeof selectFieldAttributesSchema>;
export const selectFieldValueSchema = z.object({
type: z.literal('select'),
value: z.string(),
});
export type SelectFieldValue = z.infer<typeof selectFieldValueSchema>;
export const textFieldAttributesSchema = z.object({
id: z.string(),
type: z.literal('text'),
name: z.string(),
index: z.string(),
});
export type TextFieldAttributes = z.infer<typeof textFieldAttributesSchema>;
export const textFieldValueSchema = z.object({
type: z.literal('text'),
value: ZodText.create(),
});
export type TextFieldValue = z.infer<typeof textFieldValueSchema>;
export const urlFieldAttributesSchema = z.object({
id: z.string(),
type: z.literal('url'),
name: z.string(),
index: z.string(),
});
export type UrlFieldAttributes = z.infer<typeof urlFieldAttributesSchema>;
export const urlFieldValueSchema = z.object({
type: z.literal('url'),
value: z.string(),
});
export const updatedAtFieldAttributesSchema = z.object({
id: z.string(),
type: z.literal('updatedAt'),
name: z.string(),
index: z.string(),
});
export type UpdatedAtFieldAttributes = z.infer<
typeof updatedAtFieldAttributesSchema
>;
export const updatedAtFieldValueSchema = z.object({
type: z.literal('updatedAt'),
value: z.string(),
});
export type UpdatedAtFieldValue = z.infer<typeof updatedAtFieldValueSchema>;
export const updatedByFieldAttributesSchema = z.object({
id: z.string(),
type: z.literal('updatedBy'),
name: z.string(),
index: z.string(),
});
export type UpdatedByFieldAttributes = z.infer<
typeof updatedByFieldAttributesSchema
>;
export const updatedByFieldValueSchema = z.object({
type: z.literal('updatedBy'),
value: z.string(),
});
export const fieldAttributesSchema = z.discriminatedUnion('type', [
booleanFieldAttributesSchema,
collaboratorFieldAttributesSchema,
createdAtFieldAttributesSchema,
createdByFieldAttributesSchema,
dateFieldAttributesSchema,
emailFieldAttributesSchema,
fileFieldAttributesSchema,
multiSelectFieldAttributesSchema,
numberFieldAttributesSchema,
phoneFieldAttributesSchema,
relationFieldAttributesSchema,
rollupFieldAttributesSchema,
selectFieldAttributesSchema,
textFieldAttributesSchema,
urlFieldAttributesSchema,
updatedAtFieldAttributesSchema,
updatedByFieldAttributesSchema,
]);
export type FieldAttributes = z.infer<typeof fieldAttributesSchema>;
export const fieldValueSchema = z.discriminatedUnion('type', [
booleanFieldValueSchema,
collaboratorFieldValueSchema,
createdAtFieldValueSchema,
createdByFieldValueSchema,
dateFieldValueSchema,
emailFieldValueSchema,
fileFieldValueSchema,
multiSelectFieldValueSchema,
numberFieldValueSchema,
phoneFieldValueSchema,
relationFieldValueSchema,
rollupFieldValueSchema,
selectFieldValueSchema,
textFieldValueSchema,
urlFieldValueSchema,
updatedAtFieldValueSchema,
updatedByFieldValueSchema,
]);
export type FieldValue = z.infer<typeof fieldValueSchema>;
export type FieldType = Extract<FieldAttributes['type'], string>;

View File

@@ -0,0 +1,28 @@
import { z } from 'zod';
import { NodeModel } from './core';
export const fileAttributesSchema = z.object({
type: z.literal('file'),
name: z.string(),
parentId: z.string(),
mimeType: z.string(),
size: z.number(),
extension: z.string(),
fileName: z.string(),
});
export type FileAttributes = z.infer<typeof fileAttributesSchema>;
export const fileModel: NodeModel = {
type: 'file',
schema: fileAttributesSchema,
canCreate: async (_, __) => {
return true;
},
canUpdate: async (_, __, ___) => {
return true;
},
canDelete: async (_, __) => {
return true;
},
};

View File

@@ -0,0 +1,44 @@
import { z } from 'zod';
import { NodeModel } from './core';
import { isEqual } from 'lodash';
export const folderAttributesSchema = z.object({
type: z.literal('folder'),
name: z.string(),
avatar: z.string().nullable().optional(),
parentId: z.string(),
collaborators: z.record(z.string()).nullable().optional(),
});
export type FolderAttributes = z.infer<typeof folderAttributesSchema>;
export const folderModel: NodeModel = {
type: 'folder',
schema: folderAttributesSchema,
canCreate: async (context, attributes) => {
if (attributes.type !== 'folder') {
return false;
}
const collaboratorIds = Object.keys(attributes.collaborators ?? {});
if (collaboratorIds.length > 0 && !context.hasAdminAccess()) {
return false;
}
return context.hasEditorAccess();
},
canUpdate: async (context, node, attributes) => {
if (attributes.type !== 'folder' || node.type !== 'folder') {
return false;
}
if (!isEqual(node.attributes.collaborators, attributes.collaborators)) {
return context.hasAdminAccess();
}
return context.hasEditorAccess();
},
canDelete: async (context, _) => {
return context.hasEditorAccess();
},
};

View File

@@ -0,0 +1,112 @@
import { ChannelAttributes, channelModel } from './channel';
import { NodeModel } from './core';
import { PageAttributes, pageModel } from './page';
import { ChatAttributes, chatModel } from './chat';
import { SpaceAttributes, spaceModel } from './space';
import { UserAttributes, userModel } from './user';
import { MessageAttributes, messageModel } from './message';
import { DatabaseAttributes, databaseModel } from './database';
import { FileAttributes, fileModel } from './file';
import { FolderAttributes, folderModel } from './folder';
import { RecordAttributes, recordModel } from './record';
export const registry: Record<string, NodeModel> = {
channel: channelModel,
chat: chatModel,
database: databaseModel,
file: fileModel,
folder: folderModel,
message: messageModel,
page: pageModel,
record: recordModel,
space: spaceModel,
user: userModel,
};
type NodeBase = {
id: string;
parentId: string | null;
index: string | null;
createdAt: string;
createdBy: string;
updatedAt: string | null;
updatedBy: string | null;
versionId: string;
serverCreatedAt: string | null;
serverUpdatedAt: string | null;
serverVersionId: string | null;
};
export type ChannelNode = NodeBase & {
type: 'channel';
attributes: ChannelAttributes;
};
export type ChatNode = NodeBase & {
type: 'chat';
attributes: ChatAttributes;
};
export type DatabaseNode = NodeBase & {
type: 'database';
attributes: DatabaseAttributes;
};
export type FileNode = NodeBase & {
type: 'file';
attributes: FileAttributes;
};
export type FolderNode = NodeBase & {
type: 'folder';
attributes: FolderAttributes;
};
export type MessageNode = NodeBase & {
type: 'message';
attributes: MessageAttributes;
};
export type PageNode = NodeBase & {
type: 'page';
attributes: PageAttributes;
};
export type RecordNode = NodeBase & {
type: 'record';
attributes: RecordAttributes;
};
export type SpaceNode = NodeBase & {
type: 'space';
attributes: SpaceAttributes;
};
export type UserNode = NodeBase & {
type: 'user';
attributes: UserAttributes;
};
export type Node =
| ChannelNode
| ChatNode
| DatabaseNode
| FileNode
| FolderNode
| MessageNode
| PageNode
| RecordNode
| SpaceNode
| UserNode;
export type NodeAttributes =
| UserAttributes
| SpaceAttributes
| DatabaseAttributes
| ChannelAttributes
| ChatAttributes
| FileAttributes
| FolderAttributes
| MessageAttributes
| PageAttributes
| RecordAttributes;

View File

@@ -0,0 +1,47 @@
import { z } from 'zod';
import { NodeModel } from './core';
import { blockSchema } from './block';
import { isEqual } from 'lodash';
export const messageAttributesSchema = z.object({
type: z.literal('message'),
parentId: z.string().nullable(),
content: z.record(z.string(), blockSchema),
reactions: z.record(z.array(z.string())),
});
export type MessageAttributes = z.infer<typeof messageAttributesSchema>;
export const messageModel: NodeModel = {
type: 'message',
schema: messageAttributesSchema,
canCreate: async (context, attributes) => {
if (attributes.type !== 'message') {
return false;
}
return context.hasCollaboratorAccess();
},
canUpdate: async (context, node, attributes) => {
if (attributes.type !== 'message' || node.type !== 'message') {
return false;
}
if (!isEqual(attributes.content, node.attributes.content)) {
return context.userId === node.createdBy;
}
return context.hasCollaboratorAccess();
},
canDelete: async (context, node) => {
if (node.type !== 'message') {
return false;
}
if (context.userId === node.createdBy) {
return true;
}
return context.hasAdminAccess();
},
};

View File

@@ -0,0 +1,46 @@
import { z } from 'zod';
import { NodeModel } from './core';
import { blockSchema } from './block';
import { isEqual } from 'lodash';
export const pageAttributesSchema = z.object({
type: z.literal('page'),
name: z.string(),
avatar: z.string().nullable().optional(),
parentId: z.string(),
content: z.record(blockSchema),
collaborators: z.record(z.string()).nullable().optional(),
});
export type PageAttributes = z.infer<typeof pageAttributesSchema>;
export const pageModel: NodeModel = {
type: 'page',
schema: pageAttributesSchema,
canCreate: async (context, attributes) => {
if (attributes.type !== 'page') {
return false;
}
const collaboratorIds = Object.keys(attributes.collaborators ?? {});
if (collaboratorIds.length > 0 && !context.hasAdminAccess()) {
return false;
}
return context.hasEditorAccess();
},
canUpdate: async (context, node, attributes) => {
if (attributes.type !== 'page' || node.type !== 'page') {
return false;
}
if (!isEqual(attributes.collaborators, node.attributes.collaborators)) {
return context.hasAdminAccess();
}
return context.hasEditorAccess();
},
canDelete: async (context, _) => {
return context.hasEditorAccess();
},
};

View File

@@ -0,0 +1,38 @@
import { z } from 'zod';
import { NodeModel } from './core';
import { fieldValueSchema } from './fields';
import { blockSchema } from './block';
export const recordAttributesSchema = z.object({
type: z.literal('record'),
parentId: z.string(),
databaseId: z.string(),
name: z.string(),
avatar: z.string().nullable().optional(),
fields: z.record(z.string(), fieldValueSchema),
content: z.record(blockSchema),
});
export type RecordAttributes = z.infer<typeof recordAttributesSchema>;
export const recordModel: NodeModel = {
type: 'record',
schema: recordAttributesSchema,
canCreate: async (context, attributes) => {
if (attributes.type !== 'record') {
return false;
}
return context.hasCollaboratorAccess();
},
canUpdate: async (context, node, attributes) => {
if (attributes.type !== 'record' || node.type !== 'record') {
return false;
}
return context.hasCollaboratorAccess();
},
canDelete: async (context, _) => {
return context.hasCollaboratorAccess();
},
};

View File

@@ -0,0 +1,27 @@
import { z } from 'zod';
import { NodeModel } from './core';
export const spaceAttributesSchema = z.object({
type: z.literal('space'),
name: z.string(),
parentId: z.string(),
description: z.string().nullable(),
avatar: z.string().nullable().optional(),
collaborators: z.record(z.string()),
});
export type SpaceAttributes = z.infer<typeof spaceAttributesSchema>;
export const spaceModel: NodeModel = {
type: 'space',
schema: spaceAttributesSchema,
canCreate: async (_, __) => {
return true;
},
canUpdate: async (_, __, ___) => {
return true;
},
canDelete: async (_, __) => {
return true;
},
};

View File

@@ -0,0 +1,28 @@
import { z } from 'zod';
import { NodeModel } from './core';
export const userAttributesSchema = z.object({
type: z.literal('user'),
name: z.string(),
parentId: z.string(),
email: z.string().email(),
avatar: z.string().nullable(),
accountId: z.string(),
role: z.enum(['admin', 'editor', 'collaborator', 'viewer']),
});
export type UserAttributes = z.infer<typeof userAttributesSchema>;
export const userModel: NodeModel = {
type: 'user',
schema: userAttributesSchema,
canCreate: async (_, __) => {
return true;
},
canUpdate: async (_, __, ___) => {
return true;
},
canDelete: async (_, __) => {
return true;
},
};

View File

@@ -0,0 +1,28 @@
import {
ZodType,
ZodTypeDef,
ParseInput,
ParseReturnType,
INVALID,
OK,
} from 'zod';
export class ZodText extends ZodType<string, ZodTypeDef, string> {
constructor() {
super({
description: 'Text',
});
}
_parse(input: ParseInput): ParseReturnType<string> {
if (typeof input.data !== 'string') {
return INVALID;
}
return OK(input.data);
}
static create(): ZodText {
return new ZodText();
}
}

View File

@@ -5,6 +5,6 @@
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"composite": true
}
"composite": true,
},
}

View File

@@ -1,13 +0,0 @@
import path from 'node:path';
import { configDefaults, defineConfig } from 'vitest/config';
export default defineConfig({
test: {
exclude: [...configDefaults.exclude],
},
resolve: {
alias: {
'~': path.resolve(__dirname, './src'),
},
},
});