Compare commits

...

4 Commits

Author SHA1 Message Date
Zack Pollard
2c7a29b2f4 fix: panning interrupted while moving around the map 2025-06-19 12:05:40 +01:00
SGT
0a9a520ed2 feat(server): sql-tools support for class level composite fk (#19242)
* feat: support for class level composite fk

* chore: clean up

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
2025-06-18 14:30:39 -04:00
Daniel Dietzler
de81006367 fix: album share modal navigation (#19245) 2025-06-18 16:10:35 +00:00
Jason Rasmussen
e0144b4ece feat: backfill album users (#19234) 2025-06-18 10:48:11 -04:00
35 changed files with 1106 additions and 57 deletions

View File

@@ -38,6 +38,7 @@ class SyncEntityType {
static const albumV1 = SyncEntityType._(r'AlbumV1');
static const albumDeleteV1 = SyncEntityType._(r'AlbumDeleteV1');
static const albumUserV1 = SyncEntityType._(r'AlbumUserV1');
static const albumUserBackfillV1 = SyncEntityType._(r'AlbumUserBackfillV1');
static const albumUserDeleteV1 = SyncEntityType._(r'AlbumUserDeleteV1');
static const syncAckV1 = SyncEntityType._(r'SyncAckV1');
@@ -58,6 +59,7 @@ class SyncEntityType {
albumV1,
albumDeleteV1,
albumUserV1,
albumUserBackfillV1,
albumUserDeleteV1,
syncAckV1,
];
@@ -113,6 +115,7 @@ class SyncEntityTypeTypeTransformer {
case r'AlbumV1': return SyncEntityType.albumV1;
case r'AlbumDeleteV1': return SyncEntityType.albumDeleteV1;
case r'AlbumUserV1': return SyncEntityType.albumUserV1;
case r'AlbumUserBackfillV1': return SyncEntityType.albumUserBackfillV1;
case r'AlbumUserDeleteV1': return SyncEntityType.albumUserDeleteV1;
case r'SyncAckV1': return SyncEntityType.syncAckV1;
default:

View File

@@ -13706,6 +13706,7 @@
"AlbumV1",
"AlbumDeleteV1",
"AlbumUserV1",
"AlbumUserBackfillV1",
"AlbumUserDeleteV1",
"SyncAckV1"
],

View File

@@ -4059,6 +4059,7 @@ export enum SyncEntityType {
AlbumV1 = "AlbumV1",
AlbumDeleteV1 = "AlbumDeleteV1",
AlbumUserV1 = "AlbumUserV1",
AlbumUserBackfillV1 = "AlbumUserBackfillV1",
AlbumUserDeleteV1 = "AlbumUserDeleteV1",
SyncAckV1 = "SyncAckV1"
}

View File

@@ -355,6 +355,12 @@ export const columns = {
'updateId',
'duration',
],
syncAlbumUser: [
'albums_shared_users_users.albumsId as albumId',
'albums_shared_users_users.usersId as userId',
'albums_shared_users_users.role',
'albums_shared_users_users.updateId',
],
stack: ['stack.id', 'stack.primaryAssetId', 'ownerId'],
syncAssetExif: [
'exif.assetId',

4
server/src/db.d.ts vendored
View File

@@ -98,8 +98,10 @@ export interface AlbumsSharedUsersUsers {
albumsId: string;
role: Generated<AlbumUserRole>;
usersId: string;
updatedAt: Generated<Timestamp>;
createId: Generated<string>;
createdAt: Generated<Timestamp>;
updateId: Generated<string>;
updatedAt: Generated<Timestamp>;
}
export interface ApiKeys {

View File

@@ -161,6 +161,7 @@ export type SyncItem = {
[SyncEntityType.AlbumV1]: SyncAlbumV1;
[SyncEntityType.AlbumDeleteV1]: SyncAlbumDeleteV1;
[SyncEntityType.AlbumUserV1]: SyncAlbumUserV1;
[SyncEntityType.AlbumUserBackfillV1]: SyncAlbumUserV1;
[SyncEntityType.AlbumUserDeleteV1]: SyncAlbumUserDeleteV1;
[SyncEntityType.SyncAckV1]: object;
};

View File

@@ -603,6 +603,7 @@ export enum SyncEntityType {
AlbumV1 = 'AlbumV1',
AlbumDeleteV1 = 'AlbumDeleteV1',
AlbumUserV1 = 'AlbumUserV1',
AlbumUserBackfillV1 = 'AlbumUserBackfillV1',
AlbumUserDeleteV1 = 'AlbumUserDeleteV1',
SyncAckV1 = 'SyncAckV1',

View File

@@ -394,6 +394,35 @@ where
order by
"id" asc
-- SyncRepository.getAlbumBackfill
select
"albumsId" as "id",
"createId"
from
"albums_shared_users_users"
where
"usersId" = $1
and "createId" >= $2
and "createdAt" < now() - interval '1 millisecond'
order by
"createId" asc
-- SyncRepository.getAlbumUsersBackfill
select
"albums_shared_users_users"."albumsId" as "albumId",
"albums_shared_users_users"."usersId" as "userId",
"albums_shared_users_users"."role",
"albums_shared_users_users"."updateId"
from
"albums_shared_users_users"
where
"albumsId" = $1
and "updatedAt" < now() - interval '1 millisecond'
and "updateId" < $2
and "updateId" >= $3
order by
"updateId" asc
-- SyncRepository.getAlbumUserUpserts
select
"albums_shared_users_users"."albumsId" as "albumId",

View File

@@ -254,16 +254,36 @@ export class SyncRepository {
.stream();
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
getAlbumBackfill(userId: string, afterCreateId?: string) {
return this.db
.selectFrom('albums_shared_users_users')
.select(['albumsId as id', 'createId'])
.where('usersId', '=', userId)
.$if(!!afterCreateId, (qb) => qb.where('createId', '>=', afterCreateId!))
.where('createdAt', '<', sql.raw<Date>("now() - interval '1 millisecond'"))
.orderBy('createId', 'asc')
.execute();
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID, DummyValue.UUID], stream: true })
getAlbumUsersBackfill(albumId: string, afterUpdateId: string | undefined, beforeUpdateId: string) {
return this.db
.selectFrom('albums_shared_users_users')
.select(columns.syncAlbumUser)
.where('albumsId', '=', albumId)
.where('updatedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'"))
.where('updateId', '<', beforeUpdateId)
.$if(!!afterUpdateId, (eb) => eb.where('updateId', '>=', afterUpdateId!))
.orderBy('updateId', 'asc')
.stream();
}
@GenerateSql({ params: [DummyValue.UUID], stream: true })
getAlbumUserUpserts(userId: string, ack?: SyncAck) {
return this.db
.selectFrom('albums_shared_users_users')
.select([
'albums_shared_users_users.albumsId as albumId',
'albums_shared_users_users.usersId as userId',
'albums_shared_users_users.role',
'albums_shared_users_users.updateId',
])
.select(columns.syncAlbumUser)
.where('albums_shared_users_users.updatedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'"))
.$if(!!ack, (qb) => qb.where('albums_shared_users_users.updateId', '>', ack!.updateId))
.orderBy('albums_shared_users_users.updateId', 'asc')

View File

@@ -0,0 +1,15 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "albums_shared_users_users" ADD "createId" uuid NOT NULL DEFAULT immich_uuid_v7();`.execute(db);
await sql`ALTER TABLE "albums_shared_users_users" ADD "createdAt" timestamp with time zone NOT NULL DEFAULT now();`.execute(db);
await sql`CREATE INDEX "IDX_album_users_create_id" ON "albums_shared_users_users" ("createId")`.execute(db);
await sql`CREATE INDEX "IDX_partners_create_id" ON "partners" ("createId")`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`DROP INDEX "IDX_partners_create_id";`.execute(db);
await sql`DROP INDEX "IDX_album_users_create_id";`.execute(db);
await sql`ALTER TABLE "albums_shared_users_users" DROP COLUMN "createId";`.execute(db);
await sql`ALTER TABLE "albums_shared_users_users" DROP COLUMN "createdAt";`.execute(db);
}

View File

@@ -1,4 +1,4 @@
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { CreateIdColumn, UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { AlbumUserRole } from 'src/enum';
import { album_user_after_insert, album_users_delete_audit } from 'src/schema/functions';
import { AlbumTable } from 'src/schema/tables/album.table';
@@ -7,6 +7,7 @@ import {
AfterDeleteTrigger,
AfterInsertTrigger,
Column,
CreateDateColumn,
ForeignKeyColumn,
Index,
Table,
@@ -51,6 +52,12 @@ export class AlbumUserTable {
@Column({ type: 'character varying', default: AlbumUserRole.EDITOR })
role!: AlbumUserRole;
@CreateIdColumn({ indexName: 'IDX_album_users_create_id' })
createId?: string;
@CreateDateColumn()
createdAt!: Date;
@UpdateIdColumn({ indexName: 'IDX_album_users_update_id' })
updateId?: string;

View File

@@ -27,7 +27,7 @@ export class PartnerTable {
@CreateDateColumn()
createdAt!: Date;
@CreateIdColumn()
@CreateIdColumn({ indexName: 'IDX_partners_create_id' })
createId!: string;
@UpdateDateColumn()

View File

@@ -138,14 +138,14 @@ export class SyncService extends BaseService {
break;
}
case SyncRequestType.PartnerAssetsV1: {
await this.syncPartnerAssetsV1(response, checkpointMap, auth, sessionId);
case SyncRequestType.AssetExifsV1: {
await this.syncAssetExifsV1(response, checkpointMap, auth);
break;
}
case SyncRequestType.AssetExifsV1: {
await this.syncAssetExifsV1(response, checkpointMap, auth);
case SyncRequestType.PartnerAssetsV1: {
await this.syncPartnerAssetsV1(response, checkpointMap, auth, sessionId);
break;
}
@@ -160,7 +160,7 @@ export class SyncService extends BaseService {
}
case SyncRequestType.AlbumUsersV1: {
await this.syncAlbumUsersV1(response, checkpointMap, auth);
await this.syncAlbumUsersV1(response, checkpointMap, auth, sessionId);
break;
}
@@ -330,18 +330,50 @@ export class SyncService extends BaseService {
}
}
private async syncAlbumUsersV1(response: Writable, checkpointMap: CheckpointMap, auth: AuthDto) {
const deletes = this.syncRepository.getAlbumUserDeletes(
auth.user.id,
checkpointMap[SyncEntityType.AlbumUserDeleteV1],
);
private async syncAlbumUsersV1(response: Writable, checkpointMap: CheckpointMap, auth: AuthDto, sessionId: string) {
const backfillType = SyncEntityType.AlbumUserBackfillV1;
const upsertType = SyncEntityType.AlbumUserV1;
const deleteType = SyncEntityType.AlbumUserDeleteV1;
const backfillCheckpoint = checkpointMap[backfillType];
const upsertCheckpoint = checkpointMap[upsertType];
const deletes = this.syncRepository.getAlbumUserDeletes(auth.user.id, checkpointMap[deleteType]);
for await (const { id, ...data } of deletes) {
send(response, { type: SyncEntityType.AlbumUserDeleteV1, ids: [id], data });
send(response, { type: deleteType, ids: [id], data });
}
const upserts = this.syncRepository.getAlbumUserUpserts(auth.user.id, checkpointMap[SyncEntityType.AlbumUserV1]);
const albums = await this.syncRepository.getAlbumBackfill(auth.user.id, backfillCheckpoint?.updateId);
if (upsertCheckpoint) {
const endId = upsertCheckpoint.updateId;
for (const album of albums) {
if (isEntityBackfillComplete(album, backfillCheckpoint)) {
continue;
}
const startId = getStartId(album, backfillCheckpoint);
const backfill = this.syncRepository.getAlbumUsersBackfill(album.id, startId, endId);
for await (const { updateId, ...data } of backfill) {
send(response, { type: backfillType, ids: [updateId], data });
}
sendEntityBackfillCompleteAck(response, backfillType, album.id);
}
} else if (albums.length > 0) {
await this.upsertBackfillCheckpoint({
type: backfillType,
sessionId,
createId: albums.at(-1)!.createId,
});
}
const upserts = this.syncRepository.getAlbumUserUpserts(auth.user.id, checkpointMap[upsertType]);
for await (const { updateId, ...data } of upserts) {
send(response, { type: SyncEntityType.AlbumUserV1, ids: [updateId], data });
send(response, { type: upsertType, ids: [updateId], data });
}
}

View File

@@ -1,11 +1,10 @@
import { ColumnBaseOptions } from 'src/sql-tools/from-code/decorators/column.decorator';
import { ForeignKeyAction } from 'src/sql-tools/from-code/decorators/foreign-key-constraint.decorator';
import { register } from 'src/sql-tools/from-code/register';
type Action = 'CASCADE' | 'SET NULL' | 'SET DEFAULT' | 'RESTRICT' | 'NO ACTION';
export type ForeignKeyColumnOptions = ColumnBaseOptions & {
onUpdate?: Action;
onDelete?: Action;
onUpdate?: ForeignKeyAction;
onDelete?: ForeignKeyAction;
constraintName?: string;
};

View File

@@ -0,0 +1,23 @@
import { register } from 'src/sql-tools/from-code/register';
export type ForeignKeyAction = 'CASCADE' | 'SET NULL' | 'SET DEFAULT' | 'RESTRICT' | 'NO ACTION';
export type ForeignKeyConstraintOptions = {
name?: string;
index?: boolean;
indexName?: string;
columns: string[];
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
referenceTable: () => Function;
referenceColumns?: string[];
onUpdate?: ForeignKeyAction;
onDelete?: ForeignKeyAction;
synchronize?: boolean;
};
export const ForeignKeyConstraint = (options: ForeignKeyConstraintOptions): ClassDecorator => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
return (target: Function) => {
register({ type: 'foreignKeyConstraint', item: { object: target, options } });
};
};

View File

@@ -5,7 +5,8 @@ import { processConfigurationParameters } from 'src/sql-tools/from-code/processo
import { processDatabases } from 'src/sql-tools/from-code/processors/database.processor';
import { processEnums } from 'src/sql-tools/from-code/processors/enum.processor';
import { processExtensions } from 'src/sql-tools/from-code/processors/extension.processor';
import { processForeignKeyConstraints } from 'src/sql-tools/from-code/processors/foreign-key-constriant.processor';
import { processForeignKeyColumns } from 'src/sql-tools/from-code/processors/foreign-key-column.processor';
import { processForeignKeyConstraints } from 'src/sql-tools/from-code/processors/foreign-key-constraint.processor';
import { processFunctions } from 'src/sql-tools/from-code/processors/function.processor';
import { processIndexes } from 'src/sql-tools/from-code/processors/index.processor';
import { processPrimaryKeyConstraints } from 'src/sql-tools/from-code/processors/primary-key-contraint.processor';
@@ -32,10 +33,11 @@ const processors: Processor[] = [
processFunctions,
processTables,
processColumns,
processForeignKeyColumns,
processForeignKeyConstraints,
processUniqueConstraints,
processCheckConstraints,
processPrimaryKeyConstraints,
processForeignKeyConstraints,
processIndexes,
processTriggers,
];

View File

@@ -1,7 +1,7 @@
import { ColumnOptions } from 'src/sql-tools/from-code/decorators/column.decorator';
import { onMissingTable, resolveTable } from 'src/sql-tools/from-code/processors/table.processor';
import { Processor, SchemaBuilder } from 'src/sql-tools/from-code/processors/type';
import { asMetadataKey, fromColumnValue } from 'src/sql-tools/helpers';
import { addWarning, asMetadataKey, fromColumnValue } from 'src/sql-tools/helpers';
import { DatabaseColumn } from 'src/sql-tools/types';
export const processColumns: Processor = (builder, items) => {
@@ -81,7 +81,7 @@ export const onMissingColumn = (
propertyName?: symbol | string,
) => {
const label = object.constructor.name + (propertyName ? '.' + String(propertyName) : '');
builder.warnings.push(`[${context}] Unable to find column (${label})`);
addWarning(builder, context, `Unable to find column (${label})`);
};
const METADATA_KEY = asMetadataKey('table-metadata');

View File

@@ -1,10 +1,10 @@
import { onMissingColumn, resolveColumn } from 'src/sql-tools/from-code/processors/column.processor';
import { onMissingTable, resolveTable } from 'src/sql-tools/from-code/processors/table.processor';
import { Processor } from 'src/sql-tools/from-code/processors/type';
import { asKey } from 'src/sql-tools/helpers';
import { asForeignKeyConstraintName, asKey } from 'src/sql-tools/helpers';
import { DatabaseActionType, DatabaseConstraintType } from 'src/sql-tools/types';
export const processForeignKeyConstraints: Processor = (builder, items) => {
export const processForeignKeyColumns: Processor = (builder, items) => {
for (const {
item: { object, propertyName, options, target },
} of items.filter((item) => item.type === 'foreignKeyColumn')) {
@@ -34,13 +34,16 @@ export const processForeignKeyConstraints: Processor = (builder, items) => {
column.type = referenceColumns[0].type;
}
const referenceColumnNames = referenceColumns.map((column) => column.name);
const name = options.constraintName || asForeignKeyConstraintName(table.name, columnNames);
table.constraints.push({
name: options.constraintName || asForeignKeyConstraintName(table.name, columnNames),
name,
tableName: table.name,
columnNames,
type: DatabaseConstraintType.FOREIGN_KEY,
referenceTableName: referenceTable.name,
referenceColumnNames: referenceColumns.map((column) => column.name),
referenceColumnNames,
onUpdate: options.onUpdate as DatabaseActionType,
onDelete: options.onDelete as DatabaseActionType,
synchronize: options.synchronize ?? true,
@@ -58,5 +61,4 @@ export const processForeignKeyConstraints: Processor = (builder, items) => {
}
};
const asForeignKeyConstraintName = (table: string, columns: string[]) => asKey('FK_', table, columns);
const asRelationKeyConstraintName = (table: string, columns: string[]) => asKey('REL_', table, columns);

View File

@@ -0,0 +1,86 @@
import { onMissingTable, resolveTable } from 'src/sql-tools/from-code/processors/table.processor';
import { Processor } from 'src/sql-tools/from-code/processors/type';
import { addWarning, asForeignKeyConstraintName, asIndexName } from 'src/sql-tools/helpers';
import { DatabaseActionType, DatabaseConstraintType } from 'src/sql-tools/types';
export const processForeignKeyConstraints: Processor = (builder, items, config) => {
for (const {
item: { object, options },
} of items.filter((item) => item.type === 'foreignKeyConstraint')) {
const table = resolveTable(builder, object);
if (!table) {
onMissingTable(builder, '@ForeignKeyConstraint', { name: 'referenceTable' });
continue;
}
const referenceTable = resolveTable(builder, options.referenceTable());
if (!referenceTable) {
const referenceTableName = options.referenceTable()?.name;
addWarning(
builder,
'@ForeignKeyConstraint.referenceTable',
`Unable to find table` + (referenceTableName ? ` (${referenceTableName})` : ''),
);
continue;
}
let missingColumn = false;
for (const columnName of options.columns) {
if (!table.columns.some(({ name }) => name === columnName)) {
addWarning(
builder,
'@ForeignKeyConstraint.columns',
`Unable to find column (${table.metadata.object.name}.${columnName})`,
);
missingColumn = true;
}
}
for (const columnName of options.referenceColumns || []) {
if (!referenceTable.columns.some(({ name }) => name === columnName)) {
addWarning(
builder,
'@ForeignKeyConstraint.referenceColumns',
`Unable to find column (${referenceTable.metadata.object.name}.${columnName})`,
);
missingColumn = true;
}
}
if (missingColumn) {
continue;
}
const referenceColumns =
options.referenceColumns || referenceTable.columns.filter(({ primary }) => primary).map(({ name }) => name);
const name = options.name || asForeignKeyConstraintName(table.name, options.columns);
table.constraints.push({
type: DatabaseConstraintType.FOREIGN_KEY,
name,
tableName: table.name,
columnNames: options.columns,
referenceTableName: referenceTable.name,
referenceColumnNames: referenceColumns,
onUpdate: options.onUpdate as DatabaseActionType,
onDelete: options.onDelete as DatabaseActionType,
synchronize: options.synchronize ?? true,
});
if (options.index === false) {
continue;
}
if (options.index || options.indexName || config.createForeignKeyIndexes) {
table.indexes.push({
name: options.indexName || asIndexName(table.name, options.columns),
tableName: table.name,
columnNames: options.columns,
unique: false,
synchronize: options.synchronize ?? true,
});
}
}
};

View File

@@ -1,7 +1,7 @@
import { onMissingColumn, resolveColumn } from 'src/sql-tools/from-code/processors/column.processor';
import { onMissingTable, resolveTable } from 'src/sql-tools/from-code/processors/table.processor';
import { Processor } from 'src/sql-tools/from-code/processors/type';
import { asKey } from 'src/sql-tools/helpers';
import { asIndexName } from 'src/sql-tools/helpers';
export const processIndexes: Processor = (builder, items, config) => {
for (const {
@@ -75,16 +75,3 @@ export const processIndexes: Processor = (builder, items, config) => {
});
}
};
const asIndexName = (table: string, columns?: string[], where?: string) => {
const items: string[] = [];
for (const columnName of columns ?? []) {
items.push(columnName);
}
if (where) {
items.push(where);
}
return asKey('IDX_', table, items);
};

View File

@@ -1,6 +1,6 @@
import { TableOptions } from 'src/sql-tools/from-code/decorators/table.decorator';
import { Processor, SchemaBuilder } from 'src/sql-tools/from-code/processors/type';
import { asMetadataKey, asSnakeCase } from 'src/sql-tools/helpers';
import { addWarning, asMetadataKey, asSnakeCase } from 'src/sql-tools/helpers';
export const processTables: Processor = (builder, items) => {
for (const {
@@ -45,7 +45,7 @@ export const onMissingTable = (
propertyName?: symbol | string,
) => {
const label = object.constructor.name + (propertyName ? '.' + String(propertyName) : '');
builder.warnings.push(`[${context}] Unable to find table (${label})`);
addWarning(builder, context, `Unable to find table (${label})`);
};
const METADATA_KEY = asMetadataKey('table-metadata');

View File

@@ -4,6 +4,7 @@ import { ConfigurationParameterOptions } from 'src/sql-tools/from-code/decorator
import { DatabaseOptions } from 'src/sql-tools/from-code/decorators/database.decorator';
import { ExtensionOptions } from 'src/sql-tools/from-code/decorators/extension.decorator';
import { ForeignKeyColumnOptions } from 'src/sql-tools/from-code/decorators/foreign-key-column.decorator';
import { ForeignKeyConstraintOptions } from 'src/sql-tools/from-code/decorators/foreign-key-constraint.decorator';
import { IndexOptions } from 'src/sql-tools/from-code/decorators/index.decorator';
import { TableOptions } from 'src/sql-tools/from-code/decorators/table.decorator';
import { TriggerOptions } from 'src/sql-tools/from-code/decorators/trigger.decorator';
@@ -25,5 +26,6 @@ export type RegisterItem =
| { type: 'trigger'; item: ClassBased<{ options: TriggerOptions }> }
| { type: 'extension'; item: ClassBased<{ options: ExtensionOptions }> }
| { type: 'configurationParameter'; item: ClassBased<{ options: ConfigurationParameterOptions }> }
| { type: 'foreignKeyColumn'; item: PropertyBased<{ options: ForeignKeyColumnOptions; target: () => object }> };
| { type: 'foreignKeyColumn'; item: PropertyBased<{ options: ForeignKeyColumnOptions; target: () => object }> }
| { type: 'foreignKeyConstraint'; item: ClassBased<{ options: ForeignKeyConstraintOptions }> };
export type RegisterItemType<T extends RegisterItem['type']> = Extract<RegisterItem, { type: T }>['item'];

View File

@@ -1,5 +1,6 @@
import { createHash } from 'node:crypto';
import { ColumnValue } from 'src/sql-tools/from-code/decorators/column.decorator';
import { SchemaBuilder } from 'src/sql-tools/from-code/processors/type';
import {
Comparer,
DatabaseColumn,
@@ -211,3 +212,22 @@ export const asColumnComment = (tableName: string, columnName: string, comment:
};
export const asColumnList = (columns: string[]) => columns.map((column) => `"${column}"`).join(', ');
export const asForeignKeyConstraintName = (table: string, columns: string[]) => asKey('FK_', table, [...columns]);
export const asIndexName = (table: string, columns?: string[], where?: string) => {
const items: string[] = [];
for (const columnName of columns ?? []) {
items.push(columnName);
}
if (where) {
items.push(where);
}
return asKey('IDX_', table, items);
};
export const addWarning = (builder: SchemaBuilder, context: string, message: string) => {
builder.warnings.push(`[${context}] ${message}`);
};

View File

@@ -12,6 +12,7 @@ export * from 'src/sql-tools/from-code/decorators/delete-date-column.decorator';
export * from 'src/sql-tools/from-code/decorators/extension.decorator';
export * from 'src/sql-tools/from-code/decorators/extensions.decorator';
export * from 'src/sql-tools/from-code/decorators/foreign-key-column.decorator';
export * from 'src/sql-tools/from-code/decorators/foreign-key-constraint.decorator';
export * from 'src/sql-tools/from-code/decorators/generated-column.decorator';
export * from 'src/sql-tools/from-code/decorators/index.decorator';
export * from 'src/sql-tools/from-code/decorators/primary-column.decorator';

View File

@@ -2,7 +2,7 @@ import { Kysely } from 'kysely';
import { DB } from 'src/db';
import { AlbumUserRole, SyncEntityType, SyncRequestType } from 'src/enum';
import { mediumFactory, newSyncAuthUser, newSyncTest } from 'test/medium.factory';
import { getKyselyDB } from 'test/utils';
import { getKyselyDB, wait } from 'test/utils';
let defaultDatabase: Kysely<DB>;
@@ -265,5 +265,95 @@ describe(SyncRequestType.AlbumUsersV1, () => {
},
]);
});
it('should backfill album users when a user shares an album with you', async () => {
const { auth, sut, testSync, getRepository } = await setup();
const albumRepo = getRepository('album');
const albumUserRepo = getRepository('albumUser');
const userRepo = getRepository('user');
const user1 = mediumFactory.userInsert();
const user2 = mediumFactory.userInsert();
await userRepo.create(user1);
await userRepo.create(user2);
const album1 = mediumFactory.albumInsert({ ownerId: user1.id });
const album2 = mediumFactory.albumInsert({ ownerId: user1.id });
await albumRepo.create(album1, [], []);
await albumRepo.create(album2, [], []);
// backfill album user
await albumUserRepo.create({ albumsId: album1.id, usersId: user1.id, role: AlbumUserRole.EDITOR });
await wait(2);
// initial album user
await albumUserRepo.create({ albumsId: album2.id, usersId: auth.user.id, role: AlbumUserRole.EDITOR });
await wait(2);
// post checkpoint album user
await albumUserRepo.create({ albumsId: album1.id, usersId: user2.id, role: AlbumUserRole.EDITOR });
const response = await testSync(auth, [SyncRequestType.AlbumUsersV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
albumId: album2.id,
role: AlbumUserRole.EDITOR,
userId: auth.user.id,
}),
type: SyncEntityType.AlbumUserV1,
},
]);
// ack initial user
const acks = response.map(({ ack }) => ack);
await sut.setAcks(auth, { acks });
// get access to the backfill album user
await albumUserRepo.create({ albumsId: album1.id, usersId: auth.user.id, role: AlbumUserRole.EDITOR });
// should backfill the album user
const backfillResponse = await testSync(auth, [SyncRequestType.AlbumUsersV1]);
expect(backfillResponse).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
albumId: album1.id,
role: AlbumUserRole.EDITOR,
userId: user1.id,
}),
type: SyncEntityType.AlbumUserBackfillV1,
},
{
ack: expect.stringContaining(SyncEntityType.AlbumUserBackfillV1),
data: {},
type: SyncEntityType.SyncAckV1,
},
{
ack: expect.any(String),
data: expect.objectContaining({
albumId: album1.id,
role: AlbumUserRole.EDITOR,
userId: user2.id,
}),
type: SyncEntityType.AlbumUserV1,
},
{
ack: expect.any(String),
data: expect.objectContaining({
albumId: album1.id,
role: AlbumUserRole.EDITOR,
userId: auth.user.id,
}),
type: SyncEntityType.AlbumUserV1,
},
]);
await sut.setAcks(auth, { acks: [backfillResponse[1].ack, backfillResponse.at(-1).ack] });
const finalResponse = await testSync(auth, [SyncRequestType.AlbumUsersV1]);
expect(finalResponse).toEqual([]);
});
});
});

View File

@@ -0,0 +1,124 @@
import {
Column,
DatabaseConstraintType,
DatabaseSchema,
ForeignKeyConstraint,
PrimaryColumn,
Table,
} from 'src/sql-tools';
@Table()
export class Table1 {
@PrimaryColumn({ type: 'uuid' })
id1!: string;
@PrimaryColumn({ type: 'uuid' })
id2!: string;
}
@Table()
@ForeignKeyConstraint({
columns: ['parentId1', 'parentId2'],
referenceTable: () => Table1,
referenceColumns: ['id2', 'id1'],
})
export class Table2 {
@Column({ type: 'uuid' })
parentId1!: string;
@Column({ type: 'uuid' })
parentId2!: string;
}
export const description = 'should create a foreign key constraint to the target table';
export const schema: DatabaseSchema = {
name: 'postgres',
schemaName: 'public',
functions: [],
enums: [],
extensions: [],
parameters: [],
tables: [
{
name: 'table1',
columns: [
{
name: 'id1',
tableName: 'table1',
type: 'uuid',
nullable: false,
isArray: false,
primary: true,
synchronize: true,
},
{
name: 'id2',
tableName: 'table1',
type: 'uuid',
nullable: false,
isArray: false,
primary: true,
synchronize: true,
},
],
indexes: [],
triggers: [],
constraints: [
{
type: DatabaseConstraintType.PRIMARY_KEY,
name: 'PK_e457e8b1301b7bc06ef78188ee4',
tableName: 'table1',
columnNames: ['id1', 'id2'],
synchronize: true,
},
],
synchronize: true,
},
{
name: 'table2',
columns: [
{
name: 'parentId1',
tableName: 'table2',
type: 'uuid',
nullable: false,
isArray: false,
primary: false,
synchronize: true,
},
{
name: 'parentId2',
tableName: 'table2',
type: 'uuid',
nullable: false,
isArray: false,
primary: false,
synchronize: true,
},
],
indexes: [
{
name: 'IDX_aed36d04470eba20161aa8b1dc',
tableName: 'table2',
columnNames: ['parentId1', 'parentId2'],
unique: false,
synchronize: true,
},
],
triggers: [],
constraints: [
{
type: DatabaseConstraintType.FOREIGN_KEY,
name: 'FK_aed36d04470eba20161aa8b1dc6',
tableName: 'table2',
columnNames: ['parentId1', 'parentId2'],
referenceColumnNames: ['id2', 'id1'],
referenceTableName: 'table1',
synchronize: true,
},
],
synchronize: true,
},
],
warnings: [],
};

View File

@@ -0,0 +1,78 @@
import {
Column,
DatabaseConstraintType,
DatabaseSchema,
ForeignKeyConstraint,
PrimaryColumn,
Table,
} from 'src/sql-tools';
@Table()
export class Table1 {
@PrimaryColumn({ type: 'uuid' })
id!: string;
}
@Table()
@ForeignKeyConstraint({ columns: ['parentId2'], referenceTable: () => Table1 })
export class Table2 {
@Column({ type: 'uuid' })
parentId!: string;
}
export const description = 'should warn against missing column in foreign key constraint';
export const schema: DatabaseSchema = {
name: 'postgres',
schemaName: 'public',
functions: [],
enums: [],
extensions: [],
parameters: [],
tables: [
{
name: 'table1',
columns: [
{
name: 'id',
tableName: 'table1',
type: 'uuid',
nullable: false,
isArray: false,
primary: true,
synchronize: true,
},
],
indexes: [],
triggers: [],
constraints: [
{
type: DatabaseConstraintType.PRIMARY_KEY,
name: 'PK_b249cc64cf63b8a22557cdc8537',
tableName: 'table1',
columnNames: ['id'],
synchronize: true,
},
],
synchronize: true,
},
{
name: 'table2',
columns: [
{
name: 'parentId',
tableName: 'table2',
type: 'uuid',
nullable: false,
isArray: false,
primary: false,
synchronize: true,
},
],
indexes: [],
triggers: [],
constraints: [],
synchronize: true,
},
],
warnings: ['[@ForeignKeyConstraint.columns] Unable to find column (Table2.parentId2)'],
};

View File

@@ -0,0 +1,78 @@
import {
Column,
DatabaseConstraintType,
DatabaseSchema,
ForeignKeyConstraint,
PrimaryColumn,
Table,
} from 'src/sql-tools';
@Table()
export class Table1 {
@PrimaryColumn({ type: 'uuid' })
id!: string;
}
@Table()
@ForeignKeyConstraint({ columns: ['parentId'], referenceTable: () => Table1, referenceColumns: ['foo'] })
export class Table2 {
@Column({ type: 'uuid' })
parentId!: string;
}
export const description = 'should warn against missing reference column in foreign key constraint';
export const schema: DatabaseSchema = {
name: 'postgres',
schemaName: 'public',
functions: [],
enums: [],
extensions: [],
parameters: [],
tables: [
{
name: 'table1',
columns: [
{
name: 'id',
tableName: 'table1',
type: 'uuid',
nullable: false,
isArray: false,
primary: true,
synchronize: true,
},
],
indexes: [],
triggers: [],
constraints: [
{
type: DatabaseConstraintType.PRIMARY_KEY,
name: 'PK_b249cc64cf63b8a22557cdc8537',
tableName: 'table1',
columnNames: ['id'],
synchronize: true,
},
],
synchronize: true,
},
{
name: 'table2',
columns: [
{
name: 'parentId',
tableName: 'table2',
type: 'uuid',
nullable: false,
isArray: false,
primary: false,
synchronize: true,
},
],
indexes: [],
triggers: [],
constraints: [],
synchronize: true,
},
],
warnings: ['[@ForeignKeyConstraint.referenceColumns] Unable to find column (Table1.foo)'],
};

View File

@@ -0,0 +1,44 @@
import { Column, DatabaseSchema, ForeignKeyConstraint, Table } from 'src/sql-tools';
class Foo {}
@Table()
@ForeignKeyConstraint({
columns: ['parentId'],
referenceTable: () => Foo,
})
export class Table1 {
@Column()
parentId!: string;
}
export const description = 'should warn against missing reference table';
export const schema: DatabaseSchema = {
name: 'postgres',
schemaName: 'public',
functions: [],
enums: [],
extensions: [],
parameters: [],
tables: [
{
name: 'table1',
columns: [
{
name: 'parentId',
tableName: 'table1',
type: 'character varying',
nullable: false,
isArray: false,
primary: false,
synchronize: true,
},
],
indexes: [],
triggers: [],
constraints: [],
synchronize: true,
},
],
warnings: ['[@ForeignKeyConstraint.referenceTable] Unable to find table (Foo)'],
};

View File

@@ -0,0 +1,120 @@
import {
Column,
DatabaseConstraintType,
DatabaseSchema,
ForeignKeyConstraint,
PrimaryColumn,
Table,
} from 'src/sql-tools';
@Table()
export class Table1 {
@PrimaryColumn({ type: 'uuid' })
id1!: string;
@PrimaryColumn({ type: 'uuid' })
id2!: string;
}
@Table()
@ForeignKeyConstraint({ columns: ['parentId1', 'parentId2'], referenceTable: () => Table1 })
export class Table2 {
@Column({ type: 'uuid' })
parentId1!: string;
@Column({ type: 'uuid' })
parentId2!: string;
}
export const description = 'should create a foreign key constraint to the target table';
export const schema: DatabaseSchema = {
name: 'postgres',
schemaName: 'public',
functions: [],
enums: [],
extensions: [],
parameters: [],
tables: [
{
name: 'table1',
columns: [
{
name: 'id1',
tableName: 'table1',
type: 'uuid',
nullable: false,
isArray: false,
primary: true,
synchronize: true,
},
{
name: 'id2',
tableName: 'table1',
type: 'uuid',
nullable: false,
isArray: false,
primary: true,
synchronize: true,
},
],
indexes: [],
triggers: [],
constraints: [
{
type: DatabaseConstraintType.PRIMARY_KEY,
name: 'PK_e457e8b1301b7bc06ef78188ee4',
tableName: 'table1',
columnNames: ['id1', 'id2'],
synchronize: true,
},
],
synchronize: true,
},
{
name: 'table2',
columns: [
{
name: 'parentId1',
tableName: 'table2',
type: 'uuid',
nullable: false,
isArray: false,
primary: false,
synchronize: true,
},
{
name: 'parentId2',
tableName: 'table2',
type: 'uuid',
nullable: false,
isArray: false,
primary: false,
synchronize: true,
},
],
indexes: [
{
name: 'IDX_aed36d04470eba20161aa8b1dc',
tableName: 'table2',
columnNames: ['parentId1', 'parentId2'],
unique: false,
synchronize: true,
},
],
triggers: [],
constraints: [
{
type: DatabaseConstraintType.FOREIGN_KEY,
name: 'FK_aed36d04470eba20161aa8b1dc6',
tableName: 'table2',
columnNames: ['parentId1', 'parentId2'],
referenceColumnNames: ['id1', 'id2'],
referenceTableName: 'table1',
synchronize: true,
},
],
synchronize: true,
},
],
warnings: [],
};

View File

@@ -0,0 +1,88 @@
import {
Column,
DatabaseConstraintType,
DatabaseSchema,
ForeignKeyConstraint,
PrimaryColumn,
Table,
} from 'src/sql-tools';
@Table()
export class Table1 {
@PrimaryColumn({ type: 'uuid' })
id!: string;
}
@Table()
@ForeignKeyConstraint({ columns: ['parentId'], referenceTable: () => Table1, index: false })
export class Table2 {
@Column({ type: 'uuid' })
parentId!: string;
}
export const description = 'should create a foreign key constraint to the target table without an index';
export const schema: DatabaseSchema = {
name: 'postgres',
schemaName: 'public',
functions: [],
enums: [],
extensions: [],
parameters: [],
tables: [
{
name: 'table1',
columns: [
{
name: 'id',
tableName: 'table1',
type: 'uuid',
nullable: false,
isArray: false,
primary: true,
synchronize: true,
},
],
indexes: [],
triggers: [],
constraints: [
{
type: DatabaseConstraintType.PRIMARY_KEY,
name: 'PK_b249cc64cf63b8a22557cdc8537',
tableName: 'table1',
columnNames: ['id'],
synchronize: true,
},
],
synchronize: true,
},
{
name: 'table2',
columns: [
{
name: 'parentId',
tableName: 'table2',
type: 'uuid',
nullable: false,
isArray: false,
primary: false,
synchronize: true,
},
],
indexes: [],
triggers: [],
constraints: [
{
type: DatabaseConstraintType.FOREIGN_KEY,
name: 'FK_3fcca5cc563abf256fc346e3ff4',
tableName: 'table2',
columnNames: ['parentId'],
referenceColumnNames: ['id'],
referenceTableName: 'table1',
synchronize: true,
},
],
synchronize: true,
},
],
warnings: [],
};

View File

@@ -0,0 +1,85 @@
import { Column, DatabaseConstraintType, DatabaseSchema, ForeignKeyConstraint, Table } from 'src/sql-tools';
@Table()
export class Table1 {
@Column()
foo!: string;
}
@Table()
@ForeignKeyConstraint({
columns: ['bar'],
referenceTable: () => Table1,
referenceColumns: ['foo'],
})
export class Table2 {
@Column()
bar!: string;
}
export const description = 'should create a foreign key constraint to the target table without a primary key';
export const schema: DatabaseSchema = {
name: 'postgres',
schemaName: 'public',
functions: [],
enums: [],
extensions: [],
parameters: [],
tables: [
{
name: 'table1',
columns: [
{
name: 'foo',
tableName: 'table1',
type: 'character varying',
nullable: false,
isArray: false,
primary: false,
synchronize: true,
},
],
indexes: [],
triggers: [],
constraints: [],
synchronize: true,
},
{
name: 'table2',
columns: [
{
name: 'bar',
tableName: 'table2',
type: 'character varying',
nullable: false,
isArray: false,
primary: false,
synchronize: true,
},
],
indexes: [
{
name: 'IDX_7d9c784c98d12365d198d52e4e',
tableName: 'table2',
columnNames: ['bar'],
unique: false,
synchronize: true,
},
],
triggers: [],
constraints: [
{
type: DatabaseConstraintType.FOREIGN_KEY,
name: 'FK_7d9c784c98d12365d198d52e4e6',
tableName: 'table2',
columnNames: ['bar'],
referenceTableName: 'table1',
referenceColumnNames: ['foo'],
synchronize: true,
},
],
synchronize: true,
},
],
warnings: [],
};

View File

@@ -0,0 +1,96 @@
import {
Column,
DatabaseConstraintType,
DatabaseSchema,
ForeignKeyConstraint,
PrimaryColumn,
Table,
} from 'src/sql-tools';
@Table()
export class Table1 {
@PrimaryColumn({ type: 'uuid' })
id!: string;
}
@Table()
@ForeignKeyConstraint({ columns: ['parentId'], referenceTable: () => Table1 })
export class Table2 {
@Column({ type: 'uuid' })
parentId!: string;
}
export const description = 'should create a foreign key constraint to the target table';
export const schema: DatabaseSchema = {
name: 'postgres',
schemaName: 'public',
functions: [],
enums: [],
extensions: [],
parameters: [],
tables: [
{
name: 'table1',
columns: [
{
name: 'id',
tableName: 'table1',
type: 'uuid',
nullable: false,
isArray: false,
primary: true,
synchronize: true,
},
],
indexes: [],
triggers: [],
constraints: [
{
type: DatabaseConstraintType.PRIMARY_KEY,
name: 'PK_b249cc64cf63b8a22557cdc8537',
tableName: 'table1',
columnNames: ['id'],
synchronize: true,
},
],
synchronize: true,
},
{
name: 'table2',
columns: [
{
name: 'parentId',
tableName: 'table2',
type: 'uuid',
nullable: false,
isArray: false,
primary: false,
synchronize: true,
},
],
indexes: [
{
name: 'IDX_3fcca5cc563abf256fc346e3ff',
tableName: 'table2',
columnNames: ['parentId'],
unique: false,
synchronize: true,
},
],
triggers: [],
constraints: [
{
type: DatabaseConstraintType.FOREIGN_KEY,
name: 'FK_3fcca5cc563abf256fc346e3ff4',
tableName: 'table2',
columnNames: ['parentId'],
referenceColumnNames: ['id'],
referenceTableName: 'table1',
synchronize: true,
},
],
synchronize: true,
},
],
warnings: [],
};

View File

@@ -22,7 +22,7 @@
import { isEqual, omit } from 'lodash-es';
import { DateTime, Duration } from 'luxon';
import maplibregl, { GlobeControl, type GeoJSONSource, type LngLatLike } from 'maplibre-gl';
import { onDestroy, onMount } from 'svelte';
import { onDestroy, onMount, untrack } from 'svelte';
import { t } from 'svelte-i18n';
import {
AttributionControl,
@@ -61,7 +61,7 @@
mapMarkers = $bindable(),
showSettings = true,
zoom = undefined,
center = $bindable(undefined),
center = undefined,
hash = false,
simplified = false,
clickable = false,
@@ -250,8 +250,14 @@
});
});
let lastCenter: LngLatLike | undefined = undefined;
$effect(() => {
if (!map || !center || isEqual(lastCenter, center)) {
return;
}
map?.jumpTo({ center, zoom });
lastCenter = center;
});
</script>

View File

@@ -171,7 +171,7 @@
{#if sharedLinks.length > 0}
<div class="flex justify-between items-center">
<Text>{$t('shared_links')}</Text>
<Link href={AppRoute.SHARED_LINKS} class="text-sm">{$t('view_all')}</Link>
<Link href={AppRoute.SHARED_LINKS} onclick={() => onClose()} class="text-sm">{$t('view_all')}</Link>
</div>
<Stack gap={4}>