From d4a0fcf53e4e98c91ec8915e9122b7af9ded35f7 Mon Sep 17 00:00:00 2001 From: Derek Burgman Date: Sat, 8 Oct 2022 20:58:14 -0500 Subject: [PATCH] feat: added SystemStateDocument - added FirestoreDataConverterFactory support to firestore accessor factory - added example usage of system state --- .../app/function/example/example.schedule.ts | 15 ++- .../src/app/function/example/example.spec.ts | 11 +- .../demo-firebase/src/lib/model/index.ts | 1 + .../demo-firebase/src/lib/model/service.ts | 12 ++- .../src/lib/model/system/index.ts | 1 + .../src/lib/model/system/system.ts | 19 ++++ .../common/firestore/accessor/converter.ts | 12 +++ .../lib/common/firestore/accessor/document.ts | 22 +++- .../lib/common/firestore/accessor/index.ts | 1 + packages/firebase/src/lib/model/index.ts | 1 + .../firebase/src/lib/model/system/index.ts | 1 + .../firebase/src/lib/model/system/system.ts | 100 ++++++++++++++++++ 12 files changed, 186 insertions(+), 10 deletions(-) create mode 100644 components/demo-firebase/src/lib/model/system/index.ts create mode 100644 components/demo-firebase/src/lib/model/system/system.ts create mode 100644 packages/firebase/src/lib/common/firestore/accessor/converter.ts create mode 100644 packages/firebase/src/lib/model/system/index.ts create mode 100644 packages/firebase/src/lib/model/system/system.ts diff --git a/apps/demo-api/src/app/function/example/example.schedule.ts b/apps/demo-api/src/app/function/example/example.schedule.ts index 49eba6b6e..1d07787e3 100644 --- a/apps/demo-api/src/app/function/example/example.schedule.ts +++ b/apps/demo-api/src/app/function/example/example.schedule.ts @@ -1,5 +1,16 @@ +import { loadExampleSystemState } from '@dereekb/demo-firebase'; import { DemoScheduleFunction } from '../function'; -export const exampleUsageOfSchedule: DemoScheduleFunction = (request) => { - console.log('exampleUsageOfSchedule() was called!'); +export const exampleUsageOfSchedule: DemoScheduleFunction = async (request) => { + const exampleSystemStateDocument = loadExampleSystemState(request.nest.demoFirestoreCollections.systemStateCollection.documentAccessor()); + + const currentSystemState = await exampleSystemStateDocument.snapshotData(); + + console.log(`exampleUsageOfSchedule() was called! Last update was at ${currentSystemState?.data.lastUpdate}`); + + await exampleSystemStateDocument.accessor.set({ + data: { + lastUpdate: new Date() + } + }); }; diff --git a/apps/demo-api/src/app/function/example/example.spec.ts b/apps/demo-api/src/app/function/example/example.spec.ts index 18e5cbadd..72bf46620 100644 --- a/apps/demo-api/src/app/function/example/example.spec.ts +++ b/apps/demo-api/src/app/function/example/example.spec.ts @@ -1,11 +1,12 @@ import { demoDevelopmentFunctionMap } from './../model/development.functions'; import { demoExampleUsageOfSchedule } from '../model/schedule.functions'; -import { DemoDevelopmentExampleParams, DemoDevelopmentExampleResult, DEMO_APP_EXAMPLE_DEVELOPMENT_FUNCTION_SPECIFIER } from '@dereekb/demo-firebase'; +import { DemoDevelopmentExampleParams, DemoDevelopmentExampleResult, DEMO_APP_EXAMPLE_DEVELOPMENT_FUNCTION_SPECIFIER, loadExampleSystemState } from '@dereekb/demo-firebase'; import { DemoApiFunctionContextFixture, demoApiFunctionContextFactory, demoAuthorizedUserContext } from '../../../test/fixture'; import { describeCloudFunctionTest } from '@dereekb/firebase-server/test'; import { onCallDevelopmentParams } from '@dereekb/firebase'; import { onCallDevelopmentFunction } from '@dereekb/firebase-server'; import { onCallWithDemoNestContext } from '../function'; +import { isDate } from 'date-fns'; demoApiFunctionContextFactory((f: DemoApiFunctionContextFixture) => { describeCloudFunctionTest('exampleUsageOfSchedule', { f, fns: { demoExampleUsageOfSchedule } }, ({ demoExampleUsageOfScheduleCloudFn }) => { @@ -13,7 +14,13 @@ demoApiFunctionContextFactory((f: DemoApiFunctionContextFixture) => { it('should execute the scheduled task.', async () => { const result = await u.callCloudFunction(demoExampleUsageOfScheduleCloudFn); - // expect no errors + // it should update the example system state + const exampleSystemStateDocument = loadExampleSystemState(f.instance.demoFirestoreCollections.systemStateCollection.documentAccessor()); + const currentSystemState = await exampleSystemStateDocument.snapshotData(); + + expect(currentSystemState?.data).toBeDefined(); + expect(currentSystemState?.data.lastUpdate).toBeDefined(); + expect(isDate(currentSystemState?.data.lastUpdate)).toBe(true); // date should now be set with the latest date }); }); }); diff --git a/components/demo-firebase/src/lib/model/index.ts b/components/demo-firebase/src/lib/model/index.ts index d0fcb9f69..3aaf26f81 100644 --- a/components/demo-firebase/src/lib/model/index.ts +++ b/components/demo-firebase/src/lib/model/index.ts @@ -1,3 +1,4 @@ export * from './guestbook'; export * from './profile'; export * from './service'; +export * from './system'; diff --git a/components/demo-firebase/src/lib/model/service.ts b/components/demo-firebase/src/lib/model/service.ts index c53287628..658b7ad17 100644 --- a/components/demo-firebase/src/lib/model/service.ts +++ b/components/demo-firebase/src/lib/model/service.ts @@ -1,10 +1,12 @@ -import { FirebaseAppModelContext, firebaseModelServiceFactory, firebaseModelsService, FirebasePermissionServiceModel, FirestoreContext, grantFullAccessIfAdmin, grantFullAccessIfAuthUserRelated } from '@dereekb/firebase'; +import { FirebaseAppModelContext, firebaseModelServiceFactory, firebaseModelsService, FirebasePermissionServiceModel, FirestoreContext, FirestoreDocumentAccessor, grantFullAccessIfAdmin, grantFullAccessIfAuthUserRelated, SystemState, SystemStateDocument, systemStateFirestoreCollection, SystemStateFirestoreCollection, SystemStateFirestoreCollections, SystemStateStoredData } from '@dereekb/firebase'; import { GrantedRoleMap } from '@dereekb/model'; import { PromiseOrValue } from '@dereekb/util'; import { GuestbookTypes, GuestbookFirestoreCollections, Guestbook, GuestbookDocument, GuestbookEntry, GuestbookEntryDocument, GuestbookEntryFirestoreCollectionFactory, GuestbookEntryFirestoreCollectionGroup, GuestbookEntryRoles, GuestbookFirestoreCollection, GuestbookRoles, guestbookEntryFirestoreCollectionFactory, guestbookEntryFirestoreCollectionGroup, guestbookFirestoreCollection } from './guestbook'; import { ProfileTypes, Profile, ProfileDocument, ProfileFirestoreCollection, ProfileFirestoreCollections, ProfilePrivateData, ProfilePrivateDataDocument, ProfilePrivateDataFirestoreCollectionFactory, ProfilePrivateDataFirestoreCollectionGroup, ProfilePrivateDataRoles, ProfileRoles, profileFirestoreCollection, profilePrivateDataFirestoreCollectionFactory, profilePrivateDataFirestoreCollectionGroup } from './profile'; +import { demoSystemStateStoredDataConverterMap, ExampleSystemData, EXAMPLE_SYSTEM_DATA_SYSTEM_STATE_TYPE } from './system/system'; -export abstract class DemoFirestoreCollections implements ProfileFirestoreCollections, GuestbookFirestoreCollections { +export abstract class DemoFirestoreCollections implements ProfileFirestoreCollections, GuestbookFirestoreCollections, SystemStateFirestoreCollections { + abstract readonly systemStateCollection: SystemStateFirestoreCollection; abstract readonly guestbookCollection: GuestbookFirestoreCollection; abstract readonly guestbookEntryCollectionGroup: GuestbookEntryFirestoreCollectionGroup; abstract readonly guestbookEntryCollectionFactory: GuestbookEntryFirestoreCollectionFactory; @@ -15,6 +17,7 @@ export abstract class DemoFirestoreCollections implements ProfileFirestoreCollec export function makeDemoFirestoreCollections(firestoreContext: FirestoreContext): DemoFirestoreCollections { return { + systemStateCollection: systemStateFirestoreCollection(firestoreContext, demoSystemStateStoredDataConverterMap), guestbookCollection: guestbookFirestoreCollection(firestoreContext), guestbookEntryCollectionGroup: guestbookEntryFirestoreCollectionGroup(firestoreContext), guestbookEntryCollectionFactory: guestbookEntryFirestoreCollectionFactory(firestoreContext), @@ -73,3 +76,8 @@ export type DemoFirebaseModelServiceFactories = typeof DEMO_FIREBASE_MODEL_SERVI export const demoFirebaseModelServices = firebaseModelsService(DEMO_FIREBASE_MODEL_SERVICE_FACTORIES); export type DemoFirebaseContext = DemoFirebaseBaseContext & { service: DemoFirebaseModelServiceFactories }; + +// MARK: System +export function loadExampleSystemState(accessor: FirestoreDocumentAccessor, SystemStateDocument>): SystemStateDocument { + return accessor.loadDocumentForId(EXAMPLE_SYSTEM_DATA_SYSTEM_STATE_TYPE) as SystemStateDocument; +} diff --git a/components/demo-firebase/src/lib/model/system/index.ts b/components/demo-firebase/src/lib/model/system/index.ts new file mode 100644 index 000000000..4b58cda61 --- /dev/null +++ b/components/demo-firebase/src/lib/model/system/index.ts @@ -0,0 +1 @@ +export * from './system'; diff --git a/components/demo-firebase/src/lib/model/system/system.ts b/components/demo-firebase/src/lib/model/system/system.ts new file mode 100644 index 000000000..4104ffd2d --- /dev/null +++ b/components/demo-firebase/src/lib/model/system/system.ts @@ -0,0 +1,19 @@ +import { firestoreSubObject, firestoreDate, snapshotConverterFunctions, SystemStateStoredData, SystemStateStoredDataFieldConverterConfig, SystemStateStoredDataConverterMap } from '@dereekb/firebase'; + +export const EXAMPLE_SYSTEM_DATA_SYSTEM_STATE_TYPE = 'example'; + +export interface ExampleSystemData extends SystemStateStoredData { + lastUpdate: Date; +} + +export const exampleSystemDataConverter: SystemStateStoredDataFieldConverterConfig = firestoreSubObject({ + objectField: { + fields: { + lastUpdate: firestoreDate({ saveDefaultAsNow: true }) + } + } +}); + +export const demoSystemStateStoredDataConverterMap: SystemStateStoredDataConverterMap = { + [EXAMPLE_SYSTEM_DATA_SYSTEM_STATE_TYPE]: exampleSystemDataConverter +}; diff --git a/packages/firebase/src/lib/common/firestore/accessor/converter.ts b/packages/firebase/src/lib/common/firestore/accessor/converter.ts new file mode 100644 index 000000000..813176f8a --- /dev/null +++ b/packages/firebase/src/lib/common/firestore/accessor/converter.ts @@ -0,0 +1,12 @@ +import { FactoryWithInput, FactoryWithRequiredInput, Maybe } from '@dereekb/util'; +import { DocumentReference, FirestoreDataConverter } from '../types'; + +/** + * Factory used to provide an FirestoreDataConverter based on the input reference. + */ +export type FirestoreDataConverterFactory = FactoryWithInput, DocumentReference>; + +/** + * Factory used to provide an optional custom FirestoreDataConverter based on the input reference. + */ +export type InterceptFirestoreDataConverterFactory = FactoryWithRequiredInput>, DocumentReference>; diff --git a/packages/firebase/src/lib/common/firestore/accessor/document.ts b/packages/firebase/src/lib/common/firestore/accessor/document.ts index 714513a86..a259f3c94 100644 --- a/packages/firebase/src/lib/common/firestore/accessor/document.ts +++ b/packages/firebase/src/lib/common/firestore/accessor/document.ts @@ -5,14 +5,15 @@ import { lazyFrom } from '@dereekb/rxjs'; import { Observable } from 'rxjs'; import { FirestoreAccessorDriverRef } from '../driver/accessor'; import { FirestoreCollectionNameRef, FirestoreModelId, FirestoreModelIdentityCollectionName, FirestoreModelIdentityModelType, FirestoreModelIdentityRef, FirestoreModelIdRef, FirestoreModelKey, FirestoreModelKeyRef } from './../collection/collection'; -import { DocumentReference, CollectionReference, Transaction, WriteBatch, DocumentSnapshot, SnapshotOptions, WriteResult, FirestoreDataConverter } from '../types'; +import { DocumentReference, CollectionReference, Transaction, WriteBatch, DocumentSnapshot, SnapshotOptions, WriteResult, FirestoreDataConverter, DocumentData } from '../types'; import { FirestoreAccessorIncrementUpdate, dataFromSnapshotStream, FirestoreDocumentDataAccessor, FirestoreDocumentUpdateParams, updateWithAccessorUpdateAndConverterFunction } from './accessor'; import { CollectionReferenceRef, DocumentReferenceRef, FirestoreContextReference, FirestoreDataConverterRef } from '../reference'; import { FirestoreDocumentContext } from './context'; -import { build, Maybe } from '@dereekb/util'; +import { build, Factory, Maybe } from '@dereekb/util'; import { FirestoreModelTypeRef, FirestoreModelIdentity, FirestoreModelTypeModelIdentityRef } from '../collection/collection'; import { InterceptAccessorFactoryFunction } from './accessor.wrap'; import { incrementUpdateWithAccessorFunction } from './increment'; +import { FirestoreDataConverterFactory, InterceptFirestoreDataConverterFactory } from './converter'; export interface FirestoreDocument extends FirestoreDataConverterRef, DocumentReferenceRef, CollectionReferenceRef, FirestoreModelIdentityRef, FirestoreModelTypeRef>, FirestoreCollectionNameRef>, FirestoreModelKeyRef, FirestoreModelIdRef { readonly accessor: FirestoreDocumentDataAccessor; @@ -167,6 +168,11 @@ export interface LimitedFirestoreDocumentAccessor; + + /** + * Returns the converter factory for this accessor. + */ + readonly converterFactory: FirestoreDataConverterFactory; } export interface FirestoreDocumentAccessor = FirestoreDocument> extends LimitedFirestoreDocumentAccessor, CollectionReferenceRef, FirestoreAccessorDriverRef { @@ -224,12 +230,17 @@ export interface LimitedFirestoreDocumentAccessorFactoryConfig; + /** + * Optional InterceptFirestoreDataConverterFactory to return a modified converter. + */ + readonly converterFactory?: InterceptFirestoreDataConverterFactory; readonly makeDocument: FirestoreDocumentFactoryFunction; } export function limitedFirestoreDocumentAccessorFactory = FirestoreDocument>(config: LimitedFirestoreDocumentAccessorFactoryConfig): LimitedFirestoreDocumentAccessorFactoryFunction { - const { firestoreContext, firestoreAccessorDriver, makeDocument, accessorFactory: interceptAccessorFactory, converter, modelIdentity } = config; + const { firestoreContext, firestoreAccessorDriver, makeDocument, accessorFactory: interceptAccessorFactory, converter: inputConverter, converterFactory: inputConverterFactory, modelIdentity } = config; const expectedCollectionName = firestoreAccessorDriver.fuzzedPathForPath ? firestoreAccessorDriver.fuzzedPathForPath(modelIdentity.collectionName) : modelIdentity.collectionName; + const converterFactory: FirestoreDataConverterFactory = inputConverterFactory ? (ref) => (ref ? inputConverterFactory(ref) ?? inputConverter : inputConverter) : () => inputConverter; return (context?: FirestoreDocumentContext) => { const databaseContext: FirestoreDocumentContext = context ?? config.firestoreAccessorDriver.defaultContextFactory(); @@ -240,6 +251,7 @@ export function limitedFirestoreDocumentAccessorFactory = { - converter, + converter: inputConverter, + converterFactory, modelIdentity, loadDocumentFrom(document: FirestoreDocument): D { return loadDocument(document.documentRef); diff --git a/packages/firebase/src/lib/common/firestore/accessor/index.ts b/packages/firebase/src/lib/common/firestore/accessor/index.ts index b89d484ca..a4772452c 100644 --- a/packages/firebase/src/lib/common/firestore/accessor/index.ts +++ b/packages/firebase/src/lib/common/firestore/accessor/index.ts @@ -8,6 +8,7 @@ export * from './context.batch'; export * from './context.default'; export * from './context.transaction'; export * from './context'; +export * from './converter'; export * from './document'; export * from './document.utility'; export * from './document.rxjs'; diff --git a/packages/firebase/src/lib/model/index.ts b/packages/firebase/src/lib/model/index.ts index e5abc8565..099dc6a50 100644 --- a/packages/firebase/src/lib/model/index.ts +++ b/packages/firebase/src/lib/model/index.ts @@ -1 +1,2 @@ export * from './user'; +export * from './system'; diff --git a/packages/firebase/src/lib/model/system/index.ts b/packages/firebase/src/lib/model/system/index.ts new file mode 100644 index 000000000..4b58cda61 --- /dev/null +++ b/packages/firebase/src/lib/model/system/index.ts @@ -0,0 +1 @@ +export * from './system'; diff --git a/packages/firebase/src/lib/model/system/system.ts b/packages/firebase/src/lib/model/system/system.ts new file mode 100644 index 000000000..fb6c7bfef --- /dev/null +++ b/packages/firebase/src/lib/model/system/system.ts @@ -0,0 +1,100 @@ +import { GrantedSysAdminRole } from '@dereekb/model'; +import { AbstractFirestoreDocument } from '../../common/firestore/accessor/document'; +import { FirestoreCollection, firestoreModelIdentity } from '../../common/firestore/collection/collection'; +import { FirestoreContext } from '../../common/firestore/context'; +import { snapshotConverterFunctions } from '../../common/firestore/snapshot/snapshot'; +import { CollectionReference } from '../../common/firestore/types'; +import { firestorePassThroughField } from '../../common/firestore/snapshot/snapshot.field'; +import { mapObjectMap, ModelFieldMapFunctionsConfig, cachedGetter } from '@dereekb/util'; + +// MARK: Collection +export interface SystemStateFirestoreCollections { + readonly systemStateCollection: SystemStateFirestoreCollection; +} + +export type SystemStateTypes = typeof systemStateIdentity; + +// MARK: Mock Item +export const systemStateIdentity = firestoreModelIdentity('systemState', 'sys'); + +/** + * Used to identify a SystemStateId. + */ +export type SystemStateTypeIdentifier = string; + +/** + * Used to identify a SystemStateId. + */ +export type SystemStateId = SystemStateTypeIdentifier; + +/** + * Arbitrary data stored within a SystemState. Stored values should always be either a string, number, or boolean. + */ +export type SystemStateStoredData = Record; + +/** + * A collection used for recording the current state of system subcomponents. System states for a given identifier are treated as a system-wide singleton/state/setting. + * + * For example, a SystemState with a specific SystemStateId may be relied on for information about the previous update, etc. + */ +export interface SystemState { + data: T; +} + +export type SystemStateRoles = GrantedSysAdminRole; + +/** + * Refers to a singleton SystemState based on this model's identifier. + */ +export class SystemStateDocument extends AbstractFirestoreDocument, SystemStateDocument, typeof systemStateIdentity> { + get modelIdentity() { + return systemStateIdentity; + } +} + +export const systemStateConverter = snapshotConverterFunctions({ + fields: { + data: firestorePassThroughField() + } +}); + +export function systemStateCollectionReference(context: FirestoreContext): CollectionReference { + return context.collection(systemStateIdentity.collectionName); +} + +export type SystemStateFirestoreCollection = FirestoreCollection; + +/** + * A ModelFieldMapFunctionsConfig used for data conversion. + */ +export type SystemStateStoredDataFieldConverterConfig = ModelFieldMapFunctionsConfig; + +export type SystemStateStoredDataConverterMap = { + [key: string]: SystemStateStoredDataFieldConverterConfig; +}; + +export function systemStateFirestoreCollection(firestoreContext: FirestoreContext, converters: SystemStateStoredDataConverterMap): SystemStateFirestoreCollection { + const mappedConvertersGetter = cachedGetter(() => + mapObjectMap(converters, (dataConverter) => { + return snapshotConverterFunctions({ + fields: { + data: dataConverter + } + }); + }) + ); + + return firestoreContext.firestoreCollection({ + converter: systemStateConverter, + converterFactory: (ref) => { + const type: SystemStateTypeIdentifier = ref.id; + return mappedConvertersGetter()[type]; + }, + modelIdentity: systemStateIdentity, + collection: systemStateCollectionReference(firestoreContext), + makeDocument: (a, d) => { + return new SystemStateDocument(a, d); + }, + firestoreContext + }); +}